forked from zovos/bot_tg
Compare commits
37 commits
882173f680
...
3cee61ab2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cee61ab2c | ||
|
|
2740e848e4 | ||
| a00dab8798 | |||
|
|
88ceb2ef29 | ||
| 9216bde63a | |||
|
|
3b29648a8c | ||
|
|
dd36d8f1c3 | ||
|
|
c2e210c4ae | ||
| 316d97b280 | |||
|
|
ddf63df6b6 | ||
| e9d8a331dd | |||
|
|
2295e73455 | ||
|
|
783b26003a | ||
|
|
90977abda7 | ||
| c307f48ebb | |||
|
|
cecfaa996c | ||
|
|
5e8df9c40f | ||
|
|
4a02af047a | ||
|
|
38b0c43972 | ||
|
|
298a5d7d59 | ||
|
|
a33443936c | ||
|
|
a2b72b6dfd | ||
|
|
d3ee72bca9 | ||
|
|
c756acf428 | ||
|
|
cec0256309 | ||
|
|
49957d88a8 | ||
|
|
b2556b06a9 | ||
|
|
5e91def067 | ||
|
|
924448b4e0 | ||
|
|
597839bb55 | ||
|
|
4d0a3841b7 | ||
|
|
b47e6bee81 | ||
|
|
9f8f767ae5 | ||
|
|
eeed9af545 | ||
|
|
53b6fa72ae | ||
|
|
7afd8ca216 | ||
|
|
c3487c22b4 |
28 changed files with 4434 additions and 334 deletions
0
.codex
Normal file
0
.codex
Normal file
6
.env
6
.env
|
|
@ -1,3 +1,7 @@
|
|||
BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc
|
||||
BOT_TOKEN=8505447901:AAHFkaDG0fT0tAixuJ4QP91IwMgO6cZndRM #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
|
||||
WEBAPP_URL=https://uninterleaved-scrawnily-nicol.ngrok-free.dev
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -22,3 +22,4 @@ db/
|
|||
AI/models/
|
||||
|
||||
main_legacy.py
|
||||
docker-compose.override.yaml
|
||||
|
|
|
|||
1162
AI/talk_handler.py
1162
AI/talk_handler.py
File diff suppressed because it is too large
Load diff
18
Dockerfile.api
Normal file
18
Dockerfile.api
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
FROM python:3.11-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
COPY webapp/requirements-api.txt ./requirements-api.txt
|
||||
RUN python -m pip install --upgrade pip \
|
||||
&& python -m pip install -r requirements.txt \
|
||||
&& python -m pip install -r requirements-api.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /db
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["python", "-m", "webapp.api"]
|
||||
15
README.md
15
README.md
|
|
@ -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`
|
||||
|
||||
## Команды
|
||||
|
||||
|
|
@ -77,6 +80,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` подбирает размер текста, чтобы уместить его в область макета.
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@ BET_MAX_AMOUNT = 3.0
|
|||
BET_DB_PATH = "/db/bets.sqlite3"
|
||||
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")
|
||||
MAGNIT_STORE_CODE = os.getenv("MAGNIT_STORE_CODE", "618224")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -59,7 +59,10 @@ def _init_bets_db() -> None:
|
|||
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:
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ def update_user_length(user_id: int, delta: float) -> float | None:
|
|||
if row is None:
|
||||
return None
|
||||
new_length = round(float(row[0]) + delta, 1)
|
||||
# Не даём уйти ниже 0 при проигрыше
|
||||
if delta < 0 and new_length < 0:
|
||||
new_length = 0.0
|
||||
conn.execute(
|
||||
"UPDATE penis_stats SET length = ? WHERE user_id = ?",
|
||||
(new_length, user_id),
|
||||
|
|
@ -68,6 +71,7 @@ def update_user_length(user_id: int, delta: float) -> float | None:
|
|||
return None
|
||||
|
||||
|
||||
|
||||
def play_casino(user_id: int, bet: float) -> str:
|
||||
current = get_user_length(user_id)
|
||||
if current is None:
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ async def generate_fortune(topic: str) -> tuple[str, str, str]:
|
|||
}
|
||||
|
||||
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()
|
||||
text = data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
|
|
|
|||
368
games/nude.py
368
games/nude.py
|
|
@ -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
|
||||
from aiogram import types
|
||||
|
||||
import config
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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):
|
||||
args = message.text.split(maxsplit=1)
|
||||
|
||||
if len(args) > 1 and args[1].strip() == "-h":
|
||||
help_msg = (
|
||||
"🔥 <b>Справка по команде /nude:</b>\n\n"
|
||||
"Получает случайные изображения взрослых женщин из открытых источников.\n"
|
||||
"• Использует прямое сканирование сайтов для поиска контента\n"
|
||||
"• По умолчанию ищет популярные изображения\n"
|
||||
"• Отправляет изображения напрямую\n\n"
|
||||
"<i>Пример:</i> <code>/nude</code>\n"
|
||||
"<i>По умолчанию:</i> Случайные изображения с популярных сайтов"
|
||||
"Берет случайную фотку из последних постов сайта 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
|
||||
|
||||
# Список сайтов для сканирования
|
||||
sites = [
|
||||
"https://www.imagefap.com/gallery.php?pg=random",
|
||||
"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"
|
||||
}
|
||||
timeout = aiohttp.ClientTimeout(total=60)
|
||||
history_snapshot = _load_nude_history()
|
||||
sent_urls_snapshot = set(history_snapshot["sent_images"])
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(random_site, headers=headers) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"Site access error: {resp.status}")
|
||||
await message.reply("Не удалось получить доступ к сайту :(")
|
||||
return
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
candidates = await _fetch_latest_photo_candidates(session, sent_urls_snapshot)
|
||||
|
||||
html = await resp.text()
|
||||
if not candidates:
|
||||
await message.reply("Не удалось найти свежие фотки :(")
|
||||
return
|
||||
|
||||
# Ищем URL изображений в HTML
|
||||
img_patterns = [
|
||||
r'<img[^>]+src="([^"]+)"',
|
||||
r'<a[^>]+href="([^"]+\.(jpg|jpeg|png|gif|webp))"',
|
||||
r'data-src="([^"]+)"'
|
||||
]
|
||||
async with _NUDE_HISTORY_LOCK:
|
||||
history = _load_nude_history()
|
||||
sent_urls = set(history["sent_images"])
|
||||
selected = _pick_unsent_candidate(candidates, sent_urls)
|
||||
|
||||
img_urls = []
|
||||
for pattern in img_patterns:
|
||||
matches = re.findall(pattern, html, re.IGNORECASE)
|
||||
img_urls.extend(matches)
|
||||
if selected is None:
|
||||
logger.info("Nude history exhausted current pool, resetting history")
|
||||
history = {"sent_images": []}
|
||||
selected = random.choice(candidates)
|
||||
|
||||
if not img_urls:
|
||||
await message.reply("Изображения не найдены на странице :(")
|
||||
return
|
||||
_remember_sent_image(history, selected["image_url"])
|
||||
_save_nude_history(history)
|
||||
|
||||
# Фильтруем и выбираем случайное изображение
|
||||
valid_imgs = []
|
||||
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
|
||||
caption = random.choice(CAPTIONS)
|
||||
image_bytes, filename = await _download_photo(session, selected["image_url"])
|
||||
|
||||
if any(ext in img_url.lower() for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']):
|
||||
valid_imgs.append(img_url)
|
||||
if image_bytes:
|
||||
photo = types.BufferedInputFile(image_bytes, filename=filename)
|
||||
await message.answer_photo(photo=photo, caption=caption, has_spoiler=True)
|
||||
return
|
||||
|
||||
if not valid_imgs:
|
||||
await message.reply("Подходящие изображения не найдены :(")
|
||||
return
|
||||
try:
|
||||
photo = types.URLInputFile(selected["image_url"])
|
||||
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)
|
||||
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:
|
||||
except Exception:
|
||||
logger.exception("Error in nude command")
|
||||
await message.reply("Произошла ошибка при обработке запроса :(")
|
||||
await message.reply("Произошла ошибка при получении фото :(")
|
||||
|
|
|
|||
194
games/uwu.py
194
games/uwu.py
|
|
@ -8,9 +8,151 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
CAPTIONS = [
|
||||
"Ня :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):
|
||||
args = message.text.split(maxsplit=1)
|
||||
|
||||
|
|
@ -31,57 +173,31 @@ async def handle_uwu_cmd(message: types.Message):
|
|||
if len(args) > 1:
|
||||
tags = args[1].strip()
|
||||
|
||||
tags += " order:random"
|
||||
|
||||
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)"
|
||||
}
|
||||
tags += " order:random score:>300"
|
||||
|
||||
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()
|
||||
post = await fetch_uwu_post(tags)
|
||||
file_url = post["url"]
|
||||
ext = post["ext"]
|
||||
caption = post["caption"]
|
||||
spoiler = post.get("rating") == "e" # спойлер для explicit
|
||||
|
||||
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"):
|
||||
# Для анимаций: отправляем caption отдельным сообщением,
|
||||
# а саму гифку/видео через URL напрямую — так Telegram
|
||||
# корректно показывает её как анимацию, а не как файл
|
||||
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"):
|
||||
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:
|
||||
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 :(")
|
||||
|
|
|
|||
98
main.py
98
main.py
|
|
@ -14,12 +14,13 @@ 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
|
||||
|
|
@ -47,6 +48,34 @@ _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/")
|
||||
)
|
||||
)
|
||||
|
||||
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 = {
|
||||
0: "Понедельник",
|
||||
1: "Вторник",
|
||||
|
|
@ -574,11 +603,14 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
|||
start_today = lesson_start_for(0)
|
||||
end_today = lesson_end_today()
|
||||
if start_today <= now < end_today:
|
||||
next_dt = None
|
||||
for i in range(1, 8):
|
||||
next_day = (day + i) % 7
|
||||
if next_day in lesson_days:
|
||||
next_dt = lesson_start_for(i)
|
||||
break
|
||||
if next_dt is None:
|
||||
return "Следующий урок не найден."
|
||||
delta = next_dt - now
|
||||
total_seconds = int(delta.total_seconds())
|
||||
days = total_seconds // 86400
|
||||
|
|
@ -590,6 +622,7 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
|||
f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин"
|
||||
)
|
||||
|
||||
delta = None
|
||||
for i in range(0, 8):
|
||||
next_day = (day + i) % 7
|
||||
if next_day in lesson_days:
|
||||
|
|
@ -598,12 +631,16 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
|||
delta = candidate - now
|
||||
break
|
||||
|
||||
if delta is None:
|
||||
return "Ближайший урок не найден, кайфуй."
|
||||
|
||||
total_seconds = int(delta.total_seconds())
|
||||
days = total_seconds // 86400
|
||||
hours = (total_seconds % 86400) // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин"
|
||||
|
||||
|
||||
def _prepare_magnit_query(value: str) -> str:
|
||||
value = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", " ", 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)
|
||||
|
||||
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))
|
||||
|
|
@ -1134,17 +1196,19 @@ 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()
|
||||
chat_id = message.chat.id
|
||||
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):
|
||||
audio_path = config.BASE_DIR / "maket" / "Radiohead - Creep.mp3"
|
||||
if audio_path.exists():
|
||||
|
|
@ -1174,25 +1238,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:
|
||||
|
|
@ -1395,6 +1441,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"))
|
||||
|
|
@ -1419,7 +1466,9 @@ def main():
|
|||
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_watch_image_message)
|
||||
dp.message.register(handle_keywords, F.text)
|
||||
dp.message.register(handle_unmatched_message_debug)
|
||||
|
||||
async def on_startup(bot: Bot):
|
||||
try:
|
||||
|
|
@ -1438,6 +1487,7 @@ def main():
|
|||
BotCommand(command="fetch", description="alias для /prices"),
|
||||
BotCommand(command="zvetok", description="Цветянский бля"),
|
||||
BotCommand(command="talk", description="Побазарить"),
|
||||
BotCommand(command="watch", description="Коммент фото по подписи /watch"),
|
||||
BotCommand(command="ebalnik", description="вкл/выкл автответов"),
|
||||
BotCommand(command="penis_casino", description="казино на размер"),
|
||||
BotCommand(command="gadanie", description="гадание на фене"),
|
||||
|
|
|
|||
1
webapp/__init__.py
Normal file
1
webapp/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# webapp package
|
||||
498
webapp/api.py
Normal file
498
webapp/api.py
Normal 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
82
webapp/auth.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""
|
||||
Telegram Mini App — валидация initData.
|
||||
|
||||
Telegram передаёт подписанные данные при открытии WebApp.
|
||||
Мы проверяем HMAC-SHA256 подпись через BOT_TOKEN,
|
||||
чтобы убедиться что запрос пришёл от реального пользователя.
|
||||
|
||||
Документация: https://core.telegram.org/bots/webapps#validating-data
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
BOT_TOKEN = os.getenv("BOT_TOKEN", "")
|
||||
|
||||
|
||||
def validate_init_data(init_data: str, max_age_seconds: int = 86400) -> dict | None:
|
||||
"""
|
||||
Валидирует initData из Telegram WebApp.
|
||||
|
||||
Возвращает dict с данными пользователя при успехе, None при ошибке.
|
||||
"""
|
||||
if not init_data or not BOT_TOKEN:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = parse_qs(init_data, keep_blank_values=True)
|
||||
|
||||
# Извлекаем hash
|
||||
received_hash = parsed.get("hash", [None])[0]
|
||||
if not received_hash:
|
||||
return None
|
||||
|
||||
# Собираем data-check-string (все поля кроме hash, отсортированные)
|
||||
data_pairs = []
|
||||
for key, values in parsed.items():
|
||||
if key == "hash":
|
||||
continue
|
||||
data_pairs.append(f"{key}={values[0]}")
|
||||
data_pairs.sort()
|
||||
data_check_string = "\n".join(data_pairs)
|
||||
|
||||
# Вычисляем секретный ключ
|
||||
secret_key = hmac.new(
|
||||
b"WebAppData", BOT_TOKEN.encode("utf-8"), hashlib.sha256
|
||||
).digest()
|
||||
|
||||
# Вычисляем HMAC
|
||||
computed_hash = hmac.new(
|
||||
secret_key, data_check_string.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(computed_hash, received_hash):
|
||||
return None
|
||||
|
||||
# Проверяем auth_date (не старше max_age_seconds)
|
||||
auth_date_str = parsed.get("auth_date", [None])[0]
|
||||
if auth_date_str:
|
||||
auth_date = int(auth_date_str)
|
||||
if time.time() - auth_date > max_age_seconds:
|
||||
return None
|
||||
|
||||
# Парсим user
|
||||
user_str = parsed.get("user", [None])[0]
|
||||
if not user_str:
|
||||
return None
|
||||
|
||||
user_data = json.loads(user_str)
|
||||
return {
|
||||
"user_id": user_data.get("id"),
|
||||
"first_name": user_data.get("first_name", ""),
|
||||
"last_name": user_data.get("last_name", ""),
|
||||
"username": user_data.get("username", ""),
|
||||
"language_code": user_data.get("language_code", "ru"),
|
||||
}
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
18
webapp/frontend/Dockerfile
Normal file
18
webapp/frontend/Dockerfile
Normal 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
|
||||
1114
webapp/frontend/css/style.css
Normal file
1114
webapp/frontend/css/style.css
Normal file
File diff suppressed because it is too large
Load diff
20
webapp/frontend/docker-entrypoint.d/01-render-nginx-conf.sh
Normal file
20
webapp/frontend/docker-entrypoint.d/01-render-nginx-conf.sh
Normal 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
|
||||
16
webapp/frontend/docker-entrypoint.d/05-init-self-signed.sh
Normal file
16
webapp/frontend/docker-entrypoint.d/05-init-self-signed.sh
Normal 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
|
||||
47
webapp/frontend/docker-entrypoint.d/10-certbot-loop.sh
Normal file
47
webapp/frontend/docker-entrypoint.d/10-certbot-loop.sh
Normal 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
|
||||
) &
|
||||
46
webapp/frontend/index.html
Normal file
46
webapp/frontend/index.html
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#0a0e1a">
|
||||
<title>FenyaBot</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="page-container"></div>
|
||||
<nav id="bottom-nav">
|
||||
<button class="nav-btn active" data-page="home">
|
||||
<span class="nav-icon">🏠</span>
|
||||
<span class="nav-label">Главная</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-page="betting">
|
||||
<span class="nav-icon">⚽</span>
|
||||
<span class="nav-label">Ставки</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-page="casino">
|
||||
<span class="nav-icon">🎰</span>
|
||||
<span class="nav-label">Казино</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-page="schedule">
|
||||
<span class="nav-icon">📅</span>
|
||||
<span class="nav-label">Пары</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-page="furtok">
|
||||
<span class="nav-icon">🐺</span>
|
||||
<span class="nav-label">FurTok</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-page="chat">
|
||||
<span class="nav-icon">💬</span>
|
||||
<span class="nav-label">Чат</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<script type="module" src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
46
webapp/frontend/js/api.js
Normal file
46
webapp/frontend/js/api.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* FenyaBot Mini App — API wrapper
|
||||
*/
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
function getInitData() {
|
||||
if (window.Telegram?.WebApp?.initData) {
|
||||
return window.Telegram.WebApp.initData;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const { method = 'GET', body = null } = options;
|
||||
const headers = {
|
||||
'X-Telegram-Init-Data': getInitData(),
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const config = { method, headers };
|
||||
if (body) config.body = JSON.stringify(body);
|
||||
|
||||
const resp = await fetch(`${API_BASE}${path}`, config);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export const API = {
|
||||
getProfile: () => api('/api/profile'),
|
||||
getLeagues: () => api('/api/leagues'),
|
||||
getMatches: (league) => api(`/api/matches/${league}`),
|
||||
placeBet: (data) => api('/api/bet', { method: 'POST', body: data }),
|
||||
getMyBets: () => api('/api/mybets'),
|
||||
getBetHistory: () => api('/api/bet_history'),
|
||||
playCasino: (bet) => api('/api/casino', { method: 'POST', body: { bet } }),
|
||||
playPenis: () => api('/api/penis', { method: 'POST' }),
|
||||
getTop: () => api('/api/top'),
|
||||
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
|
||||
getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`),
|
||||
getFurtokFeed: (safe = true, page = 1, 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
857
webapp/frontend/js/app.js
Normal 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');
|
||||
53
webapp/frontend/nginx.conf
Normal file
53
webapp/frontend/nginx.conf
Normal 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
18
webapp/ngrok_tunnel.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""Скрипт для локального тестирования Mini App с ngrok"""
|
||||
import time
|
||||
from pyngrok import conf, ngrok
|
||||
|
||||
conf.get_default().auth_token = "39k4ojhN5UPUWQwb1ly84ORM2IE_5JnhMaKk5p8LFM3TCetyg"
|
||||
tunnel = ngrok.connect(8080, "http")
|
||||
print(f"\n{'='*50}")
|
||||
print(f" NGROK URL: {tunnel.public_url}")
|
||||
print(f"{'='*50}\n")
|
||||
print("Туннель открыт. Не закрывай это окно!")
|
||||
print("Ctrl+C чтобы остановить.\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
ngrok.kill()
|
||||
print("Туннель закрыт.")
|
||||
2
webapp/requirements-api.txt
Normal file
2
webapp/requirements-api.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.29.0
|
||||
Loading…
Reference in a new issue