diff --git a/.env b/.env index fe834d2..b7916fa 100644 --- a/.env +++ b/.env @@ -5,3 +5,5 @@ 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 + +NGROK_AUTHTOKEN=39k4ojhN5UPUWQwb1ly84ORM2IE_5JnhMaKk5p8LFM3TCetyg diff --git a/docker-compose.webapp-test.yml b/docker-compose.webapp-test.yml new file mode 100644 index 0000000..43ea2ee --- /dev/null +++ b/docker-compose.webapp-test.yml @@ -0,0 +1,38 @@ +services: + webapp-api-test: + build: + context: . + dockerfile: Dockerfile.api + container_name: fenyabot-webapp-api-test + 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} + ports: + - "18080:8080" + restart: unless-stopped + + ngrok-test: + image: ngrok/ngrok:latest + container_name: fenyabot-ngrok-test + depends_on: + - webapp-api-test + environment: + NGROK_AUTHTOKEN: ${NGROK_AUTHTOKEN} + command: + - http + - webapp-api-test:8080 + - --log=stdout + ports: + - "4041:4040" + restart: unless-stopped diff --git a/scripts/set_telegram_webapp_button.py b/scripts/set_telegram_webapp_button.py new file mode 100755 index 0000000..a310a81 --- /dev/null +++ b/scripts/set_telegram_webapp_button.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import sys +import urllib.parse +import urllib.request +from pathlib import Path + + +def load_env_file(path: Path) -> None: + if not path.exists(): + return + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + if not key: + continue + # сохраняем первое значение и игнорируем shell-комментарий после пробела + value = value.split(" #", 1)[0].strip() + if key not in os.environ: + os.environ[key] = value + + +def api_call(token: str, method: str, payload: dict) -> dict: + url = f"https://api.telegram.org/bot{token}/{method}" + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=20) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + load_env_file(root / ".env") + + token = os.getenv("BOT_TOKEN", "").strip() + webapp_url = os.getenv("WEBAPP_URL", "").strip() + if not token: + print("BOT_TOKEN пустой в .env", file=sys.stderr) + return 1 + if not webapp_url: + print("WEBAPP_URL пустой в .env", file=sys.stderr) + return 1 + + parsed = urllib.parse.urlparse(webapp_url) + if parsed.scheme != "https": + print("WEBAPP_URL должен быть https", file=sys.stderr) + return 1 + + payload = { + "menu_button": { + "type": "web_app", + "text": "Mini App", + "web_app": {"url": webapp_url}, + } + } + result = api_call(token, "setChatMenuButton", payload) + if not result.get("ok"): + print(f"setChatMenuButton failed: {result}", file=sys.stderr) + return 1 + + print(f"Menu button updated: {webapp_url}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/start_webapp_ngrok_test.sh b/scripts/start_webapp_ngrok_test.sh new file mode 100755 index 0000000..01d432b --- /dev/null +++ b/scripts/start_webapp_ngrok_test.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="${ROOT_DIR}/docker-compose.webapp-test.yml" +ENV_FILE="${ROOT_DIR}/.env" +PROJECT_NAME="fenya-webapp-test" + +if [[ ! -f "${COMPOSE_FILE}" ]]; then + echo "Не найден ${COMPOSE_FILE}" >&2 + exit 1 +fi + +if [[ ! -f "${ENV_FILE}" ]]; then + echo "Не найден ${ENV_FILE}" >&2 + exit 1 +fi + +if ! grep -qE '^NGROK_AUTHTOKEN=' "${ENV_FILE}"; then + fallback_token="$(python - <<'PY' +import re +from pathlib import Path + +p = Path("webapp/ngrok_tunnel.py") +if not p.exists(): + print("") + raise SystemExit +txt = p.read_text(encoding="utf-8", errors="ignore") +m = re.search(r'auth_token\s*=\s*"([^"]+)"', txt) +print(m.group(1) if m else "") +PY +)" + if [[ -n "${fallback_token}" ]]; then + printf '\nNGROK_AUTHTOKEN=%s\n' "${fallback_token}" >> "${ENV_FILE}" + echo "Добавил NGROK_AUTHTOKEN в .env из webapp/ngrok_tunnel.py" + else + echo "NGROK_AUTHTOKEN не найден. Добавь в .env строку NGROK_AUTHTOKEN=..." >&2 + exit 1 + fi +fi + +cd "${ROOT_DIR}" +docker compose -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" up -d --build + +echo "Жду инициализацию ngrok..." +public_url="" +for _ in $(seq 1 45); do + public_url="$(python - <<'PY' +import json +import urllib.request + +try: + with urllib.request.urlopen("http://127.0.0.1:4041/api/tunnels", timeout=2) as resp: + data = json.load(resp) + tunnels = data.get("tunnels", []) + for t in tunnels: + url = str(t.get("public_url", "")) + if url.startswith("https://"): + print(url) + break +except Exception: + pass +PY +)" + if [[ -n "${public_url}" ]]; then + break + fi + sleep 1 +done + +if [[ -z "${public_url}" ]]; then + echo "Не удалось получить ngrok URL. Проверь логи: docker logs fenyabot-ngrok-test" >&2 + exit 1 +fi + +python - <<'PY' "${ENV_FILE}" "${public_url}" +import re +import sys +from pathlib import Path + +env_path = Path(sys.argv[1]) +url = sys.argv[2] +text = env_path.read_text(encoding="utf-8") +if re.search(r"^WEBAPP_URL=.*$", text, flags=re.MULTILINE): + text = re.sub(r"^WEBAPP_URL=.*$", f"WEBAPP_URL={url}", text, flags=re.MULTILINE) +else: + text = text.rstrip() + f"\nWEBAPP_URL={url}\n" +env_path.write_text(text, encoding="utf-8") +PY + +echo +echo "Готово." +echo "NGROK URL: ${public_url}" +echo "WEBAPP_URL обновлён в .env" +echo +echo "Для остановки тест-контура:" +echo " docker compose -p ${PROJECT_NAME} -f ${COMPOSE_FILE} down" diff --git a/scripts/stop_webapp_ngrok_test.sh b/scripts/stop_webapp_ngrok_test.sh new file mode 100755 index 0000000..3d3a908 --- /dev/null +++ b/scripts/stop_webapp_ngrok_test.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="${ROOT_DIR}/docker-compose.webapp-test.yml" +PROJECT_NAME="fenya-webapp-test" + +cd "${ROOT_DIR}" +docker compose -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" down diff --git a/webapp/api.py b/webapp/api.py index 83ca6b2..60362ad 100644 --- a/webapp/api.py +++ b/webapp/api.py @@ -10,6 +10,7 @@ import logging import os import hashlib from urllib.parse import quote +from urllib.parse import urlparse import sys from contextlib import asynccontextmanager from typing import Annotated @@ -17,7 +18,7 @@ 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.responses import Response, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel @@ -36,6 +37,7 @@ from games.betting import ( ) from webapp.auth import validate_init_data from games.uwu import fetch_uwu_post, fetch_furtok_feed +from webapp.shorties import fetch_shorties_feed logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -405,6 +407,23 @@ async def get_furtok_feed_api( raise HTTPException(status_code=500, detail=str(e)) +@app.get("/api/furtok/shorties") +async def get_shorties_feed_api( + page: int = 1, + count: int = 8, + user: dict = Depends(get_current_user), +): + """Случайная shorties-лента с Pornhub.""" + try: + # page используем как "seed-сдвиг" для подгрузки следующих рандомных наборов. + random_pages = 2 + min(max(page, 1), 6) + feed = await fetch_shorties_feed(count=count, random_pages=random_pages) + return {"feed": feed} + except Exception as e: + logger.exception("Shorties API failed") + raise HTTPException(status_code=500, detail=str(e)) + + @app.get("/api/proxy-image") async def proxy_image(url: str): """Проксирует картинку с e621 чтобы обойти hotlink protection.""" @@ -433,6 +452,76 @@ async def proxy_image(url: str): raise HTTPException(status_code=502, detail="Failed to fetch image") +def _is_allowed_shorties_media_host(hostname: str) -> bool: + host = (hostname or "").lower() + if not host: + return False + if host in {"www.pornhub.com", "pornhub.com", "phncdn.com"}: + return True + if host.endswith(".phncdn.com"): + return True + return False + + +@app.get("/api/proxy-shorties-media") +async def proxy_shorties_media(url: str, request: Request): + """Проксирует видео shorties, чтобы избежать ограничений hotlink/CORS.""" + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or not _is_allowed_shorties_media_host(parsed.hostname or ""): + raise HTTPException(status_code=403, detail="Forbidden host") + + headers = { + "User-Agent": ( + "Mozilla/5.0 (X11; Linux x86_64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/124.0.0.0 Safari/537.36" + ), + "Referer": "https://www.pornhub.com/shorties", + "Origin": "https://www.pornhub.com", + } + range_header = request.headers.get("range") + if range_header: + headers["Range"] = range_header + + timeout = aiohttp.ClientTimeout(total=120) + session = aiohttp.ClientSession(timeout=timeout) + try: + resp = await session.get(url, headers=headers, allow_redirects=True) + except aiohttp.ClientError: + await session.close() + raise HTTPException(status_code=502, detail="Failed to fetch media") + + if resp.status not in {200, 206}: + await resp.release() + await session.close() + raise HTTPException(status_code=resp.status, detail="Upstream error") + + content_type = resp.headers.get("Content-Type", "application/octet-stream") + passthrough_headers = { + "Cache-Control": "public, max-age=600", + "Accept-Ranges": resp.headers.get("Accept-Ranges", "bytes"), + } + if resp.headers.get("Content-Length"): + passthrough_headers["Content-Length"] = resp.headers["Content-Length"] + if resp.headers.get("Content-Range"): + passthrough_headers["Content-Range"] = resp.headers["Content-Range"] + + async def _stream(): + try: + async for chunk in resp.content.iter_chunked(64 * 1024): + yield chunk + finally: + await resp.release() + await session.close() + + return StreamingResponse( + _stream(), + media_type=content_type, + status_code=resp.status, + headers=passthrough_headers, + ) + + # --- ИИ Чат --- @app.post("/api/talk") diff --git a/webapp/frontend/css/style.css b/webapp/frontend/css/style.css index 47d4970..b78ee7e 100644 --- a/webapp/frontend/css/style.css +++ b/webapp/frontend/css/style.css @@ -912,7 +912,8 @@ body::before { } .furtok-card video, -.furtok-card img { +.furtok-card img, +.furtok-card iframe { position: absolute; top: 0; left: 0; @@ -920,6 +921,7 @@ body::before { height: 100%; object-fit: contain; display: block; + border: 0; } .furtok-overlay { @@ -936,6 +938,36 @@ body::before { pointer-events: none; } +.furtok-side-actions { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + z-index: 4; + display: flex; + flex-direction: column; + gap: 8px; +} + +.furtok-share-btn { + border: 1px solid rgba(255,255,255,0.35); + background: rgba(0,0,0,0.45); + color: #fff; + border-radius: 999px; + padding: 8px 12px; + font-size: 12px; + font-weight: 700; + cursor: pointer; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + transition: transform 0.15s ease, background 0.2s ease; +} + +.furtok-share-btn:active { + transform: scale(0.96); + background: rgba(0,0,0,0.65); +} + .furtok-caption { font-size: 15px; font-weight: 700; @@ -989,6 +1021,37 @@ body::before { gap: 10px; } +.furtok-mode-switch { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + border-radius: 999px; + background: rgba(255,255,255,0.14); + border: 1px solid rgba(255,255,255,0.2); +} + +.furtok-mode-btn { + border: none; + background: transparent; + color: rgba(255,255,255,0.8); + font-size: 11px; + font-weight: 700; + padding: 5px 9px; + border-radius: 999px; + cursor: pointer; + transition: background 0.2s, color 0.2s; +} + +.furtok-mode-btn.active { + background: rgba(96, 165, 250, 0.3); + color: #fff; +} + +.furtok-mode-btn:active { + transform: scale(0.96); +} + .furtok-gear-btn { background: rgba(255,255,255,0.15); border: none; diff --git a/webapp/frontend/index.html b/webapp/frontend/index.html index 02a7753..31497d3 100644 --- a/webapp/frontend/index.html +++ b/webapp/frontend/index.html @@ -10,6 +10,7 @@ +