Compare commits

...

37 commits

Author SHA1 Message Date
Hitoshi-Hub
3cee61ab2c хуй 2026-04-23 13:03:44 +03:00
Hitoshi-Hub
2740e848e4 Сделал так чтобы не отвечал на фото без /watch 2026-04-23 13:01:38 +03:00
q
a00dab8798 Спойлер для /uwu и /nude (#28) 2026-04-20 15:20:44 +00:00
Danil
88ceb2ef29 feat: spoiler for explicit content in /uwu and /nude 2026-04-20 18:11:39 +03:00
q
9216bde63a Merge pull request 'feature/FenyaBot' (#26) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#26
2026-04-16 09:19:46 +00:00
Danil
3b29648a8c fix: критические баги, FurTok картинки, image proxy, gitignore
- Удалён дубликат handle_uwu_cmd в uwu.py
- Исправлен UnboundLocalError (delta/next_dt) в main.py
- FurTok: поддержка картинок при кастомном запросе
- Добавлен image proxy /api/proxy-image для e621
- CSS: абсолютное позиционирование медиа в furtok-card
- .gitignore: docker-compose.override.yaml
2026-04-14 22:49:49 +03:00
Danil
dd36d8f1c3 FurTok: редактор тегов, подписи uwu, TikTok-скролл 2026-04-13 19:09:04 +03:00
Danil
c2e210c4ae Исправление критических багов и потенциальных проблем
- Удалена дублирующая функция handle_uwu_cmd в uwu.py (мёртвый код)

- Исправлен краш UnboundLocalError в /zvetok (next_dt и delta могли быть не определены)

- Баланс penis больше не уходит в минус при проигрыше в казино

- talk_handler теперь использует путь БД из config.py вместо своего (разные файлы БД)

- betting.py больше не крашится при импорте если /db/ недоступен

- Таймаут гадания снижен со 120 до 30 секунд (Telegram таймаутит раньше)
2026-04-13 18:50:37 +03:00
q
316d97b280 Merge pull request 'chore: sync and push recent updates' (#22) from Furrex/bot_tg:main into main
Reviewed-on: zovos/bot_tg#22
2026-04-13 13:51:31 +00:00
furrexwolfy
ddf63df6b6 Add more cute and ironic captions to uwu command 2026-04-13 16:44:18 +03:00
q
e9d8a331dd Merge pull request 'Фикс истории ставок и скролл видео' (#24) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#24
2026-04-12 19:56:24 +00:00
Danil
2295e73455 Фикс истории ставок и скролл видео 2026-04-12 22:29:32 +03:00
q
783b26003a update fronted and fix llm 2026-04-12 13:14:00 +03:00
q
90977abda7 upd nginx with tls 2026-04-12 12:53:01 +03:00
q
c307f48ebb Merge pull request 'patch_Oreshnik' (#23) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#23
2026-04-12 08:50:49 +00:00
q
cecfaa996c ai update text prompt 2026-04-12 11:06:07 +03:00
q
5e8df9c40f ai update vision promt 2026-04-12 11:01:52 +03:00
q
4a02af047a ai update fix history message 2026-04-12 10:49:10 +03:00
Omelechko Danil
38b0c43972 Merge remote-tracking branch 'upstream/main' into feature/FenyaBot 2026-04-12 04:13:23 +03:00
Omelechko Danil
298a5d7d59 Update 2026-04-12 04:11:08 +03:00
q
a33443936c add photo triget fix4 2026-04-11 17:50:10 +03:00
q
a2b72b6dfd add photo triget fix2 2026-04-11 17:43:00 +03:00
q
d3ee72bca9 add photo triget fix1 2026-04-11 17:39:34 +03:00
q
c756acf428 add photo triget fix 2026-04-11 17:35:44 +03:00
q
cec0256309 add photo triget 2026-04-11 17:31:53 +03:00
q
49957d88a8 update ai dolboeba fix4 ROMA POSHEL NAHUYx2 2026-04-11 16:49:05 +03:00
q
b2556b06a9 update ai dolboeba fix4 ROMA POSHEL NAHUY 2026-04-11 16:44:51 +03:00
q
5e91def067 update ai dolboeba fix3 2026-04-11 16:26:46 +03:00
q
924448b4e0 update ai dolboeba fix2 2026-04-11 16:24:14 +03:00
q
597839bb55 update ai dolboeba fix1 2026-04-11 16:19:36 +03:00
q
4d0a3841b7 update ai dolboeba 2026-04-11 16:15:14 +03:00
Omelechko Danil
b47e6bee81 Update 2026-04-11 00:59:38 +03:00
q
9f8f767ae5 some fix for nude save db with 2026-04-10 12:14:20 +03:00
q
eeed9af545 some fix for nude save db with history with pic 2026-04-10 12:01:20 +03:00
q
53b6fa72ae some fix for nude change word 2026-04-10 11:20:14 +03:00
q
7afd8ca216 some fix for nude 2026-04-10 11:17:45 +03:00
Omelechko Danil
c3487c22b4 feat: TG Mini App - FastAPI backend + frontend SPA + Docker setup 2026-04-04 23:03:17 +03:00
28 changed files with 4434 additions and 334 deletions

0
.codex Normal file
View file

6
.env
View file

@ -1,3 +1,7 @@
BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc BOT_TOKEN=8505447901:AAHFkaDG0fT0tAixuJ4QP91IwMgO6cZndRM #8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc
TZ=Europe/Moscow TZ=Europe/Moscow
ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923 ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923
LLAMA_API_URL=https://mirror.porno4free.ru/zovos-ai/
LLAMA_FORCE_DISABLE_THINKING=0
LLAMA_LOG_THINKING=1
WEBAPP_URL=https://uninterleaved-scrawnily-nicol.ngrok-free.dev

1
.gitignore vendored
View file

@ -22,3 +22,4 @@ db/
AI/models/ AI/models/
main_legacy.py main_legacy.py
docker-compose.override.yaml

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`. Переменные окружения: задайте `BOT_TOKEN` в `.env`.
Для ИИ-фич нужен `LLAMA_API_URL`. Для ИИ-фич нужен `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). Для ставок нужен `ODDS_API_KEY` (бесплатно на https://the-odds-api.com).
Контейнер хранит состояние в `./db`: Контейнер хранит состояние в `./db`:
@ -39,6 +41,7 @@ docker compose down
- `chat_history.sqlite3` - `chat_history.sqlite3`
- `polychaetsi_stats.json` - `polychaetsi_stats.json`
- `bets.sqlite3` - `bets.sqlite3`
- `nude_history.json`
## Команды ## Команды
@ -77,6 +80,18 @@ docker compose down
- В образ копируется весь проект, а runtime-данные исключены через `.dockerignore`. - В образ копируется весь проект, а runtime-данные исключены через `.dockerignore`.
- Если меняешь код или картинки, достаточно пересобрать: `docker compose up --build -d`. - Если меняешь код или картинки, достаточно пересобрать: `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` подбирает размер текста, чтобы уместить его в область макета. - `/maket` подбирает размер текста, чтобы уместить его в область макета.

View file

@ -47,6 +47,14 @@ BET_MAX_AMOUNT = 3.0
BET_DB_PATH = "/db/bets.sqlite3" BET_DB_PATH = "/db/bets.sqlite3"
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно) 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 ---
MAGNIT_API_BASE_URL = os.getenv("MAGNIT_API_BASE_URL", "https://mirror.porno4free.ru/magnit") MAGNIT_API_BASE_URL = os.getenv("MAGNIT_API_BASE_URL", "https://mirror.porno4free.ru/magnit")
MAGNIT_STORE_CODE = os.getenv("MAGNIT_STORE_CODE", "618224") MAGNIT_STORE_CODE = os.getenv("MAGNIT_STORE_CODE", "618224")

View file

@ -11,5 +11,52 @@ 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
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 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"
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

@ -59,7 +59,10 @@ def _init_bets_db() -> None:
conn.commit() conn.commit()
_init_bets_db() try:
_init_bets_db()
except Exception:
logger.warning("Could not init bets DB at import time (will retry on first use)")
def remember_user_sport_context(user_id: int, sport_alias: str) -> None: def remember_user_sport_context(user_id: int, sport_alias: str) -> None:

View file

@ -57,6 +57,9 @@ def update_user_length(user_id: int, delta: float) -> float | None:
if row is None: if row is None:
return None return None
new_length = round(float(row[0]) + delta, 1) new_length = round(float(row[0]) + delta, 1)
# Не даём уйти ниже 0 при проигрыше
if delta < 0 and new_length < 0:
new_length = 0.0
conn.execute( conn.execute(
"UPDATE penis_stats SET length = ? WHERE user_id = ?", "UPDATE penis_stats SET length = ? WHERE user_id = ?",
(new_length, user_id), (new_length, user_id),
@ -68,6 +71,7 @@ def update_user_length(user_id: int, delta: float) -> float | None:
return None return None
def play_casino(user_id: int, bet: float) -> str: def play_casino(user_id: int, bet: float) -> str:
current = get_user_length(user_id) current = get_user_length(user_id)
if current is None: if current is None:

View file

@ -30,7 +30,7 @@ async def generate_fortune(topic: str) -> tuple[str, str, str]:
} }
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp: async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
data = await resp.json() data = await resp.json()
text = data["choices"][0]["message"]["content"].strip() text = data["choices"][0]["message"]["content"].strip()

View file

@ -1,108 +1,320 @@
import asyncio
import json
import logging
import os
import random
from pathlib import Path, PurePosixPath
from urllib.parse import urlparse
import aiohttp import aiohttp
from aiogram import types from aiogram import types
import config import config
import logging
import random
import re
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CAPTIONS = [ CAPTIONS = [
"Hot stuff 🔥", "Шо ты блять псюнчик решил свой подергать?",
"Sexy content 😏" "Решил подрочить сука?",
"гнойный писюн у тебя кнч, да",
"Ух нихуя, это ШПАЧИХА!?",
] ]
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): async def handle_nude_cmd(message: types.Message):
args = message.text.split(maxsplit=1) args = message.text.split(maxsplit=1)
if len(args) > 1 and args[1].strip() == "-h": if len(args) > 1 and args[1].strip() == "-h":
help_msg = ( help_msg = (
"🔥 <b>Справка по команде /nude:</b>\n\n" "🔥 <b>Справка по команде /nude:</b>\n\n"
"Получает случайные изображения взрослых женщин из открытых источников.\n" "Берет случайную фотку из последних постов сайта le saint des seins.\n"
"• Использует прямое сканирование сайтов для поиска контента\n" "• Источник: <code>https://lesaintdesseins.fr/</code>\n"
"• По умолчанию ищет популярные изображения\n" "• Пагинацией тянет 1000+ последних постов через WordPress REST API\n"
"• Отправляет изображения напрямую\n\n" "Хранит историю отправок и старается не повторять фотки\n"
"<i>Пример:</i> <code>/nude</code>\n" "• Выбирает случайную картинку и отправляет ее прямо в Telegram\n\n"
"<i>По умолчанию:</i> Случайные изображения с популярных сайтов" "<i>Пример:</i> <code>/nude</code>"
) )
await message.reply(help_msg, parse_mode="HTML") await message.reply(help_msg, parse_mode="HTML")
return return
# Список сайтов для сканирования timeout = aiohttp.ClientTimeout(total=60)
sites = [ history_snapshot = _load_nude_history()
"https://www.imagefap.com/gallery.php?pg=random", sent_urls_snapshot = set(history_snapshot["sent_images"])
"https://xhamster.com/photos?sort=random",
"https://spankbang.com/random"
]
random_site = random.choice(sites)
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1"
}
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(random_site, headers=headers) as resp: candidates = await _fetch_latest_photo_candidates(session, sent_urls_snapshot)
if resp.status != 200:
logger.error(f"Site access error: {resp.status}")
await message.reply("Не удалось получить доступ к сайту :(")
return
html = await resp.text() if not candidates:
await message.reply("Не удалось найти свежие фотки :(")
return
# Ищем URL изображений в HTML async with _NUDE_HISTORY_LOCK:
img_patterns = [ history = _load_nude_history()
r'<img[^>]+src="([^"]+)"', sent_urls = set(history["sent_images"])
r'<a[^>]+href="([^"]+\.(jpg|jpeg|png|gif|webp))"', selected = _pick_unsent_candidate(candidates, sent_urls)
r'data-src="([^"]+)"'
]
img_urls = [] if selected is None:
for pattern in img_patterns: logger.info("Nude history exhausted current pool, resetting history")
matches = re.findall(pattern, html, re.IGNORECASE) history = {"sent_images": []}
img_urls.extend(matches) selected = random.choice(candidates)
if not img_urls: _remember_sent_image(history, selected["image_url"])
await message.reply("Изображения не найдены на странице :(") _save_nude_history(history)
return
# Фильтруем и выбираем случайное изображение caption = random.choice(CAPTIONS)
valid_imgs = [] image_bytes, filename = await _download_photo(session, selected["image_url"])
for img_url in img_urls:
if img_url.startswith('//'):
img_url = 'https:' + img_url
elif img_url.startswith('/'):
if 'imagefap.com' in random_site:
img_url = 'https://www.imagefap.com' + img_url
elif 'xhamster.com' in random_site:
img_url = 'https://xhamster.com' + img_url
elif 'spankbang.com' in random_site:
img_url = 'https://spankbang.com' + img_url
if any(ext in img_url.lower() for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']): if image_bytes:
valid_imgs.append(img_url) photo = types.BufferedInputFile(image_bytes, filename=filename)
await message.answer_photo(photo=photo, caption=caption, has_spoiler=True)
return
if not valid_imgs: try:
await message.reply("Подходящие изображения не найдены :(") photo = types.URLInputFile(selected["image_url"])
return await message.answer_photo(photo=photo, caption=caption, has_spoiler=True)
except Exception:
logger.exception("Failed to send photo by URL")
await message.answer(
f"{caption}\n{selected['image_url']}\n{selected['post_url']}"
)
final_img_url = random.choice(valid_imgs) except Exception:
caption = random.choice(CAPTIONS)
# Отправляем изображение
try:
input_file = types.URLInputFile(final_img_url)
await message.answer_photo(photo=input_file, caption=caption)
except Exception as e:
logger.error(f"Error sending photo: {e}")
await message.answer(f"{caption}\n{final_img_url}")
except Exception as e:
logger.exception("Error in nude command") logger.exception("Error in nude command")
await message.reply("Произошла ошибка при обработке запроса :(") await message.reply("Произошла ошибка при получении фото :(")

View file

@ -8,9 +8,151 @@ logger = logging.getLogger(__name__)
CAPTIONS = [ CAPTIONS = [
"Ня :3", "Ня :3",
"uwu" "uwu",
"OwO",
"Мурк :3",
"Мяу~",
"Фыр-фыр",
"Лапки! 🐾",
"Какой пушистик! :3",
"Милота!",
"Гав!",
"Ауф",
"Няшно!",
"Смотри какая прелесть",
"owo what's this",
"Твой пушистый друг",
"🦊",
"🐶",
"🐱",
"мур-мяу",
"пуньк",
"кусь",
">w<",
"^w^",
"Хвостатый сюрприз",
"Держи пушистика :3",
"Оуо",
"Ави!",
"UwU",
"~nya",
"Господи, опять e621...",
"Осуждаю, но смотрю",
"Мама, я фурри",
"Держи своего кринж-пушистика",
"Надеюсь, тебе не стыдно",
"Товарищ майор уже выехал",
"Слишком много интернета на сегодня",
"Для этого и придумали интернет",
"*Тяжелый вздох*",
"Лучше бы на завод пошел",
"Опять дрочи... ОЙ ТО ЕСТЬ НЯ :3",
"Удали интернет",
"И зачем я это только парсю...",
"Смотри, но только никому не рассказывай",
"Эххх... uwu...",
"В дурке сегодня день открытых дверей",
"Я нейросеть, помогите, меня держат в заложниках"
] ]
async def fetch_uwu_post(tags: str) -> dict:
url = "https://e621.net/posts.json"
params = {
"tags": tags,
"limit": 1
}
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", [])
if not posts:
raise Exception("Ничего не найдено по этим тегам :(")
post = posts[0]
file_url = post.get("file", {}).get("url")
ext = post.get("file", {}).get("ext", "png")
rating = post.get("rating", "s") # s=safe, q=questionable, e=explicit
if not file_url:
raise Exception("У найденного поста нет прямого URL изображения :(")
caption = random.choice(CAPTIONS)
return {
"url": file_url,
"ext": ext,
"caption": caption,
"rating": rating
}
async def fetch_furtok_feed(safe: bool = True, page: int = 1, extra_tags: str = "") -> list[dict]:
is_custom = bool(extra_tags and extra_tags.strip())
rating = "rating:safe" if safe else "-rating:safe"
if is_custom:
# Кастомный запрос: без принудительного animated, поддержка картинок
tags = f"{extra_tags.strip()} order:random score:>200"
else:
# Стандартная лента: только видео
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()
VIDEO_EXTS = ("gif", "webm", "mp4")
IMAGE_EXTS = ("png", "jpg", "jpeg", "webp")
allowed_exts = VIDEO_EXTS + IMAGE_EXTS if is_custom else VIDEO_EXTS
posts = data.get("posts", [])
feed = []
for post in posts:
file_url = post.get("file", {}).get("url")
ext = post.get("file", {}).get("ext", "")
if not file_url or ext not in allowed_exts:
continue
sample_url = post.get("sample", {}).get("url") or file_url
media_type = "image" if ext in IMAGE_EXTS else "video"
feed.append({
"url": file_url,
"sample": sample_url,
"ext": ext,
"type": media_type,
"score": post.get("score", {}).get("total", 0),
"fav_count": post.get("fav_count", 0),
})
return feed
async def handle_uwu_cmd(message: types.Message): async def handle_uwu_cmd(message: types.Message):
args = message.text.split(maxsplit=1) args = message.text.split(maxsplit=1)
@ -31,57 +173,31 @@ 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"
url = "https://e621.net/posts.json"
params = {
"tags": tags,
"limit": 1
}
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)"
}
try: try:
async with aiohttp.ClientSession() as session: post = await fetch_uwu_post(tags)
async with session.get(url, params=params, headers=headers, auth=auth) as resp: file_url = post["url"]
if resp.status != 200: ext = post["ext"]
logger.error(f"e621 API Error: {resp.status}") caption = post["caption"]
await message.reply("Не удалось получить картинку от сервера :(") spoiler = post.get("rating") == "e" # спойлер для explicit
return
data = await resp.json()
posts = data.get("posts", [])
if not posts:
await message.reply("Ничего не найдено по этим тегам :(")
return
post = posts[0]
file_url = post.get("file", {}).get("url")
ext = post.get("file", {}).get("ext", "png")
if not file_url:
await message.reply("У найденного поста нет прямого URL изображения :(")
return
caption = random.choice(CAPTIONS)
if ext in ("gif", "webm", "mp4"): if ext in ("gif", "webm", "mp4"):
# Для анимаций: отправляем caption отдельным сообщением, # Для анимаций: отправляем caption отдельным сообщением,
# а саму гифку/видео через URL напрямую — так Telegram # а саму гифку/видео через URL напрямую — так Telegram
# корректно показывает её как анимацию, а не как файл # корректно показывает её как анимацию, а не как файл
await message.answer(caption) await message.answer(caption)
await message.answer_animation(animation=file_url) await message.answer_animation(animation=file_url, has_spoiler=spoiler)
elif ext in ("png", "jpg", "jpeg", "webp"): elif ext in ("png", "jpg", "jpeg", "webp"):
input_file = types.URLInputFile(file_url) input_file = types.URLInputFile(file_url)
await message.answer_photo(photo=input_file, caption=caption) await message.answer_photo(photo=input_file, caption=caption, has_spoiler=spoiler)
else: else:
input_file = types.URLInputFile(file_url) input_file = types.URLInputFile(file_url)
await message.answer_document(document=input_file, caption=caption) await message.answer_document(document=input_file, caption=caption)
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 :(")

98
main.py
View file

@ -14,12 +14,13 @@ 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
# Локальные импорты # Локальные импорты
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.casino import play_casino
from games.fortune import generate_fortune from games.fortune import generate_fortune
from games.uwu import handle_uwu_cmd from games.uwu import handle_uwu_cmd
@ -47,6 +48,34 @@ _polychaetsi_lock = threading.Lock()
_polychaetsi_word_re = re.compile(r"[а-яё]+", re.IGNORECASE) _polychaetsi_word_re = re.compile(r"[а-яё]+", re.IGNORECASE)
_no_lesson_re = re.compile(r"(нету\s+пары|нет\s+пары|пары\s+нету|пары\s+нет)", 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/")
)
)
def _is_watch_image_message(message: Message) -> bool:
if not _is_supported_image_message(message):
return False
caption = (message.caption or "").strip()
return bool(re.match(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s|$)", caption, flags=re.IGNORECASE))
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 = { _weekday_names = {
0: "Понедельник", 0: "Понедельник",
1: "Вторник", 1: "Вторник",
@ -574,11 +603,14 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
start_today = lesson_start_for(0) start_today = lesson_start_for(0)
end_today = lesson_end_today() end_today = lesson_end_today()
if start_today <= now < end_today: if start_today <= now < end_today:
next_dt = None
for i in range(1, 8): for i in range(1, 8):
next_day = (day + i) % 7 next_day = (day + i) % 7
if next_day in lesson_days: if next_day in lesson_days:
next_dt = lesson_start_for(i) next_dt = lesson_start_for(i)
break break
if next_dt is None:
return "Следующий урок не найден."
delta = next_dt - now delta = next_dt - now
total_seconds = int(delta.total_seconds()) total_seconds = int(delta.total_seconds())
days = total_seconds // 86400 days = total_seconds // 86400
@ -590,6 +622,7 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин" f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин"
) )
delta = None
for i in range(0, 8): for i in range(0, 8):
next_day = (day + i) % 7 next_day = (day + i) % 7
if next_day in lesson_days: if next_day in lesson_days:
@ -598,12 +631,16 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
delta = candidate - now delta = candidate - now
break break
if delta is None:
return "Ближайший урок не найден, кайфуй."
total_seconds = int(delta.total_seconds()) total_seconds = int(delta.total_seconds())
days = total_seconds // 86400 days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600 hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60 minutes = (total_seconds % 3600) // 60
return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин" return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин"
def _prepare_magnit_query(value: str) -> str: def _prepare_magnit_query(value: str) -> str:
value = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", " ", value) value = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", " ", value)
value = re.sub(r"[_-]+", " ", value) value = re.sub(r"[_-]+", " ", value)
@ -949,7 +986,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))
@ -1134,17 +1196,19 @@ async def handle_gen_mem(message: Message, bot: Bot):
return return
await message.reply_photo(BufferedInputFile(output.getvalue(), filename="meme.jpg")) await message.reply_photo(BufferedInputFile(output.getvalue(), filename="meme.jpg"))
_last_autoreply_ts = 0
_autoreply_disabled_chats: set[int] = set() _autoreply_disabled_chats: set[int] = set()
async def handle_keywords(message: Message): async def handle_keywords(message: Message):
global _last_autoreply_ts
if not message.text or message.text.startswith('/'): if not message.text or message.text.startswith('/'):
return return
text_lower = message.text.lower() text_lower = message.text.lower()
chat_id = message.chat.id chat_id = message.chat.id
user_name = (message.from_user.first_name or "кент") if message.from_user else "кент" user_name = (message.from_user.first_name or "кент") if message.from_user else "кент"
# /ebalnik должен глушить все авто-реакции в чате, включая медиа/фото-ветки.
if chat_id in _autoreply_disabled_chats:
return
if any(re.search(rf'\b{word}\b', text_lower) for word in config.KEYWORDS): if any(re.search(rf'\b{word}\b', text_lower) for word in config.KEYWORDS):
audio_path = config.BASE_DIR / "maket" / "Radiohead - Creep.mp3" audio_path = config.BASE_DIR / "maket" / "Radiohead - Creep.mp3"
if audio_path.exists(): if audio_path.exists():
@ -1174,25 +1238,7 @@ async def handle_keywords(message: Message):
if chat_id in _autoreply_disabled_chats: if chat_id in _autoreply_disabled_chats:
return return
import time as _time await handle_chat_message(message, store_message=False, allow_autonomous=True)
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")
async def handle_penis_casino_cmd(message: Message): async def handle_penis_casino_cmd(message: Message):
if not message.from_user: if not message.from_user:
@ -1395,6 +1441,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"))
@ -1419,7 +1466,9 @@ def main():
dp.message.register(handle_gen_mem, Command("gen_mem")) dp.message.register(handle_gen_mem, Command("gen_mem"))
dp.message.register(handle_uwu_cmd, Command("uwu")) dp.message.register(handle_uwu_cmd, Command("uwu"))
dp.message.register(handle_nude_cmd, Command("nude")) dp.message.register(handle_nude_cmd, Command("nude"))
dp.message.register(handle_photo_message, _is_watch_image_message)
dp.message.register(handle_keywords, F.text) dp.message.register(handle_keywords, F.text)
dp.message.register(handle_unmatched_message_debug)
async def on_startup(bot: Bot): async def on_startup(bot: Bot):
try: try:
@ -1438,6 +1487,7 @@ def main():
BotCommand(command="fetch", description="alias для /prices"), BotCommand(command="fetch", description="alias для /prices"),
BotCommand(command="zvetok", description="Цветянский бля"), BotCommand(command="zvetok", description="Цветянский бля"),
BotCommand(command="talk", description="Побазарить"), BotCommand(command="talk", description="Побазарить"),
BotCommand(command="watch", description="Коммент фото по подписи /watch"),
BotCommand(command="ebalnik", description="вкл/выкл автответов"), BotCommand(command="ebalnik", description="вкл/выкл автответов"),
BotCommand(command="penis_casino", description="казино на размер"), BotCommand(command="penis_casino", description="казино на размер"),
BotCommand(command="gadanie", description="гадание на фене"), BotCommand(command="gadanie", description="гадание на фене"),

1
webapp/__init__.py Normal file
View file

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

498
webapp/api.py Normal file
View file

@ -0,0 +1,498 @@
"""
FenyaBot Mini App FastAPI Backend.
REST API для Telegram Mini App. Использует те же SQLite базы и
бизнес-логику что и основной бот.
"""
import asyncio
import logging
import os
import hashlib
from urllib.parse import quote
import sys
from contextlib import asynccontextmanager
from typing import Annotated
import aiohttp
from fastapi import FastAPI, Depends, Header, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
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,
tags: str = "",
user: dict = Depends(get_current_user),
):
"""Получить ленту анимированных постов для FurTok."""
try:
feed = await fetch_furtok_feed(safe=safe, page=page, extra_tags=tags)
return {"feed": feed}
except Exception as e:
logger.exception("FurTok API failed")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/proxy-image")
async def proxy_image(url: str):
"""Проксирует картинку с e621 чтобы обойти hotlink protection."""
allowed = ("static1.e621.net", "static2.e621.net", "static1.e926.net")
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.hostname not in allowed:
raise HTTPException(status_code=403, detail="Forbidden host")
headers = {
"User-Agent": f"FenyaBot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)",
}
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200:
raise HTTPException(status_code=resp.status, detail="Upstream error")
content_type = resp.content_type or "image/png"
body = await resp.read()
return Response(
content=body,
media_type=content_type,
headers={"Cache-Control": "public, max-age=86400"},
)
except aiohttp.ClientError:
raise HTTPException(status_code=502, detail="Failed to fetch image")
# --- ИИ Чат ---
@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, tags = '') => api(`/api/furtok?safe=${safe}&page=${page}${tags ? '&tags=' + encodeURIComponent(tags) : ''}`),
talk: (text) => api('/api/talk', { method: 'POST', body: { text } }),
};

857
webapp/frontend/js/app.js Normal file
View file

@ -0,0 +1,857 @@
/**
* 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;
let furtokCurrentIndex = 0;
let furtokCards = [];
let furtokCustomTags = '';
// Подписи из uwu.py — рандомно появляются на карточках
const FURTOK_CAPTIONS = [
"Ня :3", "uwu", "OwO", "Мурк :3", "Мяу~", "Фыр-фыр", "Лапки! 🐾",
"Какой пушистик! :3", "Милота!", "Гав!", "Ауф", "Няшно!",
"Смотри какая прелесть", "owo what's this", "Твой пушистый друг",
"🦊", "🐶", "🐱", "мур-мяу", "пуньк", "кусь", ">w<", "^w^",
"Хвостатый сюрприз", "Держи пушистика :3", "Оуо", "Ави!", "UwU", "~nya",
"Господи, опять e621...", "Осуждаю, но смотрю", "Мама, я фурри",
"Держи своего кринж-пушистика", "Надеюсь, тебе не стыдно",
"Товарищ майор уже выехал", "Слишком много интернета на сегодня",
"Для этого и придумали интернет", "*Тяжелый вздох*",
"Лучше бы на завод пошел", "Опять дрочи... ОЙ ТО ЕСТЬ НЯ :3",
"Удали интернет", "И зачем я это только парсю...",
"Смотри, но только никому не рассказывай", "Эххх... uwu...",
"В дурке сегодня день открытых дверей",
"Я нейросеть, помогите, меня держат в заложниках"
];
function randomCaption() {
return FURTOK_CAPTIONS[Math.floor(Math.random() * FURTOK_CAPTIONS.length)];
}
function _furtokGetTags() {
// Если кастомные теги заданы — используем их (safe управляется юзером через теги)
if (furtokCustomTags.trim()) return furtokCustomTags.trim();
// Иначе стандартный запрос с safe-переключателем
const rating = furtokSafe ? 'rating:safe' : '-rating:safe';
return `${rating} score:>200`;
}
function _furtokReload(feed) {
furtokPage = 1;
furtokCurrentIndex = 0;
furtokCards = [];
feed.innerHTML = '';
loadFurtokPage(feed);
}
function renderFurtok() {
container.innerHTML = '';
document.querySelector('.furtok-wrapper')?.remove();
furtokPage = 1;
furtokLoading = false;
furtokCurrentIndex = 0;
furtokCards = [];
furtokWrapper = document.createElement('div');
furtokWrapper.className = 'furtok-wrapper';
furtokWrapper.innerHTML = `
<div class="furtok-header">
<div class="furtok-title">🐺 FurTok</div>
<div class="furtok-header-right">
<label class="furtok-toggle" id="furtok-safe-wrap" ${furtokCustomTags.trim() ? 'style="display:none"' : ''}>
<span>Safe</span>
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}>
</label>
<button class="furtok-gear-btn" id="furtok-gear"></button>
</div>
</div>
<div class="furtok-tags-panel" id="furtok-tags-panel" style="display:none">
<div class="furtok-tags-row">
<input type="text" id="furtok-tags-input" class="furtok-tags-input"
placeholder="rating:safe score:>200 wolf"
value="${furtokCustomTags}">
<button class="furtok-tags-apply" id="furtok-tags-apply">🔄</button>
</div>
<div class="furtok-tags-hint">e621 теги через пробел. Пусто = стандартный запрос + Safe</div>
</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');
const safeWrap = document.getElementById('furtok-safe-wrap');
const gearBtn = document.getElementById('furtok-gear');
const tagsPanel = document.getElementById('furtok-tags-panel');
const tagsInput = document.getElementById('furtok-tags-input');
const tagsApply = document.getElementById('furtok-tags-apply');
// Safe toggle
safeToggle.addEventListener('change', () => {
furtokSafe = safeToggle.checked;
_furtokReload(feed);
});
// Gear button — toggle tags panel
gearBtn.addEventListener('click', () => {
haptic();
const visible = tagsPanel.style.display !== 'none';
tagsPanel.style.display = visible ? 'none' : 'flex';
if (!visible) tagsInput.focus();
});
// Apply tags
tagsApply.addEventListener('click', () => {
haptic();
furtokCustomTags = tagsInput.value.trim();
// Если кастомные теги — прячем Safe (юзер сам контролирует rating)
safeWrap.style.display = furtokCustomTags ? 'none' : '';
tagsPanel.style.display = 'none';
_furtokReload(feed);
});
// Enter в поле тегов
tagsInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') tagsApply.click();
});
// Жёсткий свайп как в TikTok
setupTikTokScroll(feed);
loadFurtokPage(feed);
}
function setupTikTokScroll(feed) {
let touchStartY = 0;
let touchDeltaY = 0;
let isSwiping = false;
feed.addEventListener('touchstart', (e) => {
touchStartY = e.touches[0].clientY;
touchDeltaY = 0;
isSwiping = true;
}, { passive: true });
feed.addEventListener('touchmove', (e) => {
if (!isSwiping) return;
touchDeltaY = e.touches[0].clientY - touchStartY;
// Показываем визуальный сдвиг текущей карточки
const current = furtokCards[furtokCurrentIndex];
if (current) {
const clamped = Math.max(-120, Math.min(120, touchDeltaY));
current.style.transform = `translateY(${clamped}px)`;
current.style.transition = 'none';
}
}, { passive: true });
feed.addEventListener('touchend', () => {
if (!isSwiping) return;
isSwiping = false;
const current = furtokCards[furtokCurrentIndex];
if (current) {
current.style.transform = '';
current.style.transition = 'transform 0.3s ease';
}
const threshold = 50;
if (touchDeltaY < -threshold && furtokCurrentIndex < furtokCards.length - 1) {
// Свайп вверх — следующая
furtokCurrentIndex++;
scrollToCard(feed);
haptic();
// Подгрузка если близко к концу
if (furtokCurrentIndex >= furtokCards.length - 2 && !furtokLoading) {
furtokPage++;
loadFurtokPage(feed);
}
} else if (touchDeltaY > threshold && furtokCurrentIndex > 0) {
// Свайп вниз — предыдущая
furtokCurrentIndex--;
scrollToCard(feed);
haptic();
}
touchDeltaY = 0;
});
// Десктоп: колесо мыши
let wheelLock = false;
feed.addEventListener('wheel', (e) => {
e.preventDefault();
if (wheelLock) return;
wheelLock = true;
setTimeout(() => { wheelLock = false; }, 400);
if (e.deltaY > 0 && furtokCurrentIndex < furtokCards.length - 1) {
furtokCurrentIndex++;
scrollToCard(feed);
if (furtokCurrentIndex >= furtokCards.length - 2 && !furtokLoading) {
furtokPage++;
loadFurtokPage(feed);
}
} else if (e.deltaY < 0 && furtokCurrentIndex > 0) {
furtokCurrentIndex--;
scrollToCard(feed);
}
}, { passive: false });
}
function scrollToCard(feed) {
const card = furtokCards[furtokCurrentIndex];
if (!card) return;
feed.scrollTo({ top: card.offsetTop, behavior: 'smooth' });
// Управляем видео: стопим все, играем текущее
furtokCards.forEach((c, i) => {
const v = c.querySelector('video');
if (!v) return;
if (i === furtokCurrentIndex) {
v.play().catch(() => {});
} else {
v.pause();
}
});
}
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, furtokCustomTags);
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.type === 'image' || post.ext === 'gif') {
mediaHtml = `<img src="${post.url}" loading="lazy">`;
} else {
mediaHtml = `<video src="${post.url}" loop playsinline preload="metadata" muted></video>`;
}
const caption = randomCaption();
card.innerHTML = `
${mediaHtml}
<div class="furtok-overlay">
<div class="furtok-caption">${caption}</div>
<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);
furtokCards.push(card);
});
// Автоплей первого видео при первой загрузке
if (furtokCurrentIndex === 0 && furtokCards.length > 0) {
const firstVideo = furtokCards[0].querySelector('video');
if (firstVideo) firstVideo.play().catch(() => {});
}
haptic('success');
} catch (e) {
loader.remove();
haptic('error');
if (furtokPage === 1) {
feedEl.innerHTML = `<div class="furtok-loader">❌ ${e.message}</div>`;
}
}
furtokLoading = false;
}
// ══════════════════════════════════════════
// 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