Merge pull request 'feature/FenyaBot' (#26) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: #26
This commit is contained in:
commit
9216bde63a
11 changed files with 373 additions and 71 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -22,3 +22,4 @@ db/
|
||||||
AI/models/
|
AI/models/
|
||||||
|
|
||||||
main_legacy.py
|
main_legacy.py
|
||||||
|
docker-compose.override.yaml
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Основные настройки поведения и стиля бота редактируются здесь.
|
# Основные настройки поведения и стиля бота редактируются здесь.
|
||||||
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
||||||
DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or str(Path(__file__).resolve().with_name("chat_history.sqlite3"))
|
DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or getattr(__import__("config"), "CHAT_HISTORY_DB_PATH", str(Path(__file__).resolve().with_name("chat_history.sqlite3")))
|
||||||
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
|
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
|
||||||
SKIP_TOKEN = "<skip>"
|
SKIP_TOKEN = "<skip>"
|
||||||
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
|
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,10 @@ def _init_bets_db() -> None:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
_init_bets_db()
|
try:
|
||||||
|
_init_bets_db()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not init bets DB at import time (will retry on first use)")
|
||||||
|
|
||||||
|
|
||||||
def remember_user_sport_context(user_id: int, sport_alias: str) -> None:
|
def remember_user_sport_context(user_id: int, sport_alias: str) -> None:
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,9 @@ def update_user_length(user_id: int, delta: float) -> float | None:
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
new_length = round(float(row[0]) + delta, 1)
|
new_length = round(float(row[0]) + delta, 1)
|
||||||
|
# Не даём уйти ниже 0 при проигрыше
|
||||||
|
if delta < 0 and new_length < 0:
|
||||||
|
new_length = 0.0
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE penis_stats SET length = ? WHERE user_id = ?",
|
"UPDATE penis_stats SET length = ? WHERE user_id = ?",
|
||||||
(new_length, user_id),
|
(new_length, user_id),
|
||||||
|
|
@ -68,6 +71,7 @@ def update_user_length(user_id: int, delta: float) -> float | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def play_casino(user_id: int, bet: float) -> str:
|
def play_casino(user_id: int, bet: float) -> str:
|
||||||
current = get_user_length(user_id)
|
current = get_user_length(user_id)
|
||||||
if current is None:
|
if current is None:
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ async def generate_fortune(topic: str) -> tuple[str, str, str]:
|
||||||
}
|
}
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp:
|
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
text = data["choices"][0]["message"]["content"].strip()
|
text = data["choices"][0]["message"]["content"].strip()
|
||||||
|
|
||||||
|
|
|
||||||
41
games/uwu.py
41
games/uwu.py
|
|
@ -55,28 +55,6 @@ CAPTIONS = [
|
||||||
"Я нейросеть, помогите, меня держат в заложниках"
|
"Я нейросеть, помогите, меня держат в заложниках"
|
||||||
]
|
]
|
||||||
|
|
||||||
async def handle_uwu_cmd(message: types.Message):
|
|
||||||
args = message.text.split(maxsplit=1)
|
|
||||||
|
|
||||||
if len(args) > 1 and args[1].strip() == "-h":
|
|
||||||
help_msg = (
|
|
||||||
"🐈 <b>Справка по тегам e621:</b>\n\n"
|
|
||||||
"Перечислите любые теги через пробел после команды.\n"
|
|
||||||
"• <b>rating</b>: <code>rating:safe</code> (безопасные), <code>rating:questionable</code>, <code>rating:explicit</code> (NSFW)\n"
|
|
||||||
"• <b>score</b>: <code>score:>500</code> (искать популярные посты с рейтингом больше 500)\n"
|
|
||||||
"• Исключить тег: поставьте минус, например <code>-fox</code>\n\n"
|
|
||||||
"<i>Пример:</i> <code>/uwu wolf -rating:explicit score:>100</code>\n"
|
|
||||||
"<i>По умолчанию:</i> <code>rating:safe score:>500 -animated</code>"
|
|
||||||
)
|
|
||||||
await message.reply(help_msg, parse_mode="HTML")
|
|
||||||
return
|
|
||||||
|
|
||||||
tags = "rating:safe score:>500 -animated"
|
|
||||||
if len(args) > 1:
|
|
||||||
tags = args[1].strip()
|
|
||||||
|
|
||||||
tags += " order:random score:>300"
|
|
||||||
|
|
||||||
async def fetch_uwu_post(tags: str) -> dict:
|
async def fetch_uwu_post(tags: str) -> dict:
|
||||||
url = "https://e621.net/posts.json"
|
url = "https://e621.net/posts.json"
|
||||||
params = {
|
params = {
|
||||||
|
|
@ -116,9 +94,16 @@ async def fetch_uwu_post(tags: str) -> dict:
|
||||||
"caption": caption
|
"caption": caption
|
||||||
}
|
}
|
||||||
|
|
||||||
async def fetch_furtok_feed(safe: bool = True, page: int = 1) -> list[dict]:
|
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"
|
rating = "rating:safe" if safe else "-rating:safe"
|
||||||
tags = f"{rating} animated order:random score:>200"
|
|
||||||
|
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"
|
url = "https://e621.net/posts.json"
|
||||||
params = {
|
params = {
|
||||||
|
|
@ -142,18 +127,24 @@ async def fetch_furtok_feed(safe: bool = True, page: int = 1) -> list[dict]:
|
||||||
raise Exception("Не удалось загрузить ленту :(")
|
raise Exception("Не удалось загрузить ленту :(")
|
||||||
data = await resp.json()
|
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", [])
|
posts = data.get("posts", [])
|
||||||
feed = []
|
feed = []
|
||||||
for post in posts:
|
for post in posts:
|
||||||
file_url = post.get("file", {}).get("url")
|
file_url = post.get("file", {}).get("url")
|
||||||
ext = post.get("file", {}).get("ext", "")
|
ext = post.get("file", {}).get("ext", "")
|
||||||
if not file_url or ext not in ("gif", "webm", "mp4"):
|
if not file_url or ext not in allowed_exts:
|
||||||
continue
|
continue
|
||||||
sample_url = post.get("sample", {}).get("url") or file_url
|
sample_url = post.get("sample", {}).get("url") or file_url
|
||||||
|
media_type = "image" if ext in IMAGE_EXTS else "video"
|
||||||
feed.append({
|
feed.append({
|
||||||
"url": file_url,
|
"url": file_url,
|
||||||
"sample": sample_url,
|
"sample": sample_url,
|
||||||
"ext": ext,
|
"ext": ext,
|
||||||
|
"type": media_type,
|
||||||
"score": post.get("score", {}).get("total", 0),
|
"score": post.get("score", {}).get("total", 0),
|
||||||
"fav_count": post.get("fav_count", 0),
|
"fav_count": post.get("fav_count", 0),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
8
main.py
8
main.py
|
|
@ -597,11 +597,14 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
||||||
start_today = lesson_start_for(0)
|
start_today = lesson_start_for(0)
|
||||||
end_today = lesson_end_today()
|
end_today = lesson_end_today()
|
||||||
if start_today <= now < end_today:
|
if start_today <= now < end_today:
|
||||||
|
next_dt = None
|
||||||
for i in range(1, 8):
|
for i in range(1, 8):
|
||||||
next_day = (day + i) % 7
|
next_day = (day + i) % 7
|
||||||
if next_day in lesson_days:
|
if next_day in lesson_days:
|
||||||
next_dt = lesson_start_for(i)
|
next_dt = lesson_start_for(i)
|
||||||
break
|
break
|
||||||
|
if next_dt is None:
|
||||||
|
return "Следующий урок не найден."
|
||||||
delta = next_dt - now
|
delta = next_dt - now
|
||||||
total_seconds = int(delta.total_seconds())
|
total_seconds = int(delta.total_seconds())
|
||||||
days = total_seconds // 86400
|
days = total_seconds // 86400
|
||||||
|
|
@ -613,6 +616,7 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
||||||
f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин"
|
f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
delta = None
|
||||||
for i in range(0, 8):
|
for i in range(0, 8):
|
||||||
next_day = (day + i) % 7
|
next_day = (day + i) % 7
|
||||||
if next_day in lesson_days:
|
if next_day in lesson_days:
|
||||||
|
|
@ -621,12 +625,16 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
||||||
delta = candidate - now
|
delta = candidate - now
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if delta is None:
|
||||||
|
return "Ближайший урок не найден, кайфуй."
|
||||||
|
|
||||||
total_seconds = int(delta.total_seconds())
|
total_seconds = int(delta.total_seconds())
|
||||||
days = total_seconds // 86400
|
days = total_seconds // 86400
|
||||||
hours = (total_seconds % 86400) // 3600
|
hours = (total_seconds % 86400) // 3600
|
||||||
minutes = (total_seconds % 3600) // 60
|
minutes = (total_seconds % 3600) // 60
|
||||||
return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин"
|
return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин"
|
||||||
|
|
||||||
|
|
||||||
def _prepare_magnit_query(value: str) -> str:
|
def _prepare_magnit_query(value: str) -> str:
|
||||||
value = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", " ", value)
|
value = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", " ", value)
|
||||||
value = re.sub(r"[_-]+", " ", value)
|
value = re.sub(r"[_-]+", " ", value)
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,16 @@ REST API для Telegram Mini App. Использует те же SQLite баз
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import hashlib
|
||||||
|
from urllib.parse import quote
|
||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import FastAPI, Depends, Header, HTTPException
|
import aiohttp
|
||||||
|
from fastapi import FastAPI, Depends, Header, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
@ -389,17 +393,46 @@ async def get_uwu_api(
|
||||||
async def get_furtok_feed_api(
|
async def get_furtok_feed_api(
|
||||||
safe: bool = True,
|
safe: bool = True,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
|
tags: str = "",
|
||||||
user: dict = Depends(get_current_user),
|
user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Получить ленту анимированных постов для FurTok."""
|
"""Получить ленту анимированных постов для FurTok."""
|
||||||
try:
|
try:
|
||||||
feed = await fetch_furtok_feed(safe=safe, page=page)
|
feed = await fetch_furtok_feed(safe=safe, page=page, extra_tags=tags)
|
||||||
return {"feed": feed}
|
return {"feed": feed}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("FurTok API failed")
|
logger.exception("FurTok API failed")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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")
|
@app.post("/api/talk")
|
||||||
|
|
|
||||||
|
|
@ -891,6 +891,8 @@ body::before {
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
scroll-snap-type: y mandatory;
|
scroll-snap-type: y mandatory;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.furtok-feed::-webkit-scrollbar { display: none; }
|
.furtok-feed::-webkit-scrollbar { display: none; }
|
||||||
|
|
@ -911,9 +913,13 @@ body::before {
|
||||||
|
|
||||||
.furtok-card video,
|
.furtok-card video,
|
||||||
.furtok-card img {
|
.furtok-card img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.furtok-overlay {
|
.furtok-overlay {
|
||||||
|
|
@ -924,11 +930,21 @@ body::before {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: linear-gradient(transparent, rgba(0,0,0,0.7));
|
background: linear-gradient(transparent, rgba(0,0,0,0.7));
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.furtok-caption {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 1px 6px rgba(0,0,0,0.8);
|
||||||
|
max-width: 80%;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.furtok-stats {
|
.furtok-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
@ -937,6 +953,7 @@ body::before {
|
||||||
color: rgba(255,255,255,0.85);
|
color: rgba(255,255,255,0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.furtok-header {
|
.furtok-header {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -966,6 +983,31 @@ body::before {
|
||||||
color: rgba(255,255,255,0.8);
|
color: rgba(255,255,255,0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.furtok-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-gear-btn {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-gear-btn:hover,
|
||||||
|
.furtok-gear-btn:active {
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.furtok-toggle input[type="checkbox"] {
|
.furtok-toggle input[type="checkbox"] {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
|
|
@ -998,6 +1040,68 @@ body::before {
|
||||||
transform: translateX(18px);
|
transform: translateX(18px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tags editor panel */
|
||||||
|
.furtok-tags-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 48px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 6;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-tags-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-tags-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-tags-input::placeholder {
|
||||||
|
color: rgba(255,255,255,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-tags-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-tags-apply {
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-tags-apply:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-tags-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255,255,255,0.45);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.furtok-loader {
|
.furtok-loader {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,6 @@ export const API = {
|
||||||
getTop: () => api('/api/top'),
|
getTop: () => api('/api/top'),
|
||||||
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
|
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
|
||||||
getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`),
|
getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`),
|
||||||
getFurtokFeed: (safe = true, page = 1) => api(`/api/furtok?safe=${safe}&page=${page}`),
|
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 } }),
|
talk: (text) => api('/api/talk', { method: 'POST', body: { text } }),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -504,26 +504,78 @@ let furtokSafe = true;
|
||||||
let furtokPage = 1;
|
let furtokPage = 1;
|
||||||
let furtokLoading = false;
|
let furtokLoading = false;
|
||||||
let furtokWrapper = null;
|
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() {
|
function renderFurtok() {
|
||||||
// Очищаем основной контейнер и создаём полноэкранную обёртку
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
// Удаляем старый wrapper если есть
|
|
||||||
document.querySelector('.furtok-wrapper')?.remove();
|
document.querySelector('.furtok-wrapper')?.remove();
|
||||||
|
|
||||||
furtokPage = 1;
|
furtokPage = 1;
|
||||||
furtokLoading = false;
|
furtokLoading = false;
|
||||||
|
furtokCurrentIndex = 0;
|
||||||
|
furtokCards = [];
|
||||||
|
|
||||||
furtokWrapper = document.createElement('div');
|
furtokWrapper = document.createElement('div');
|
||||||
furtokWrapper.className = 'furtok-wrapper';
|
furtokWrapper.className = 'furtok-wrapper';
|
||||||
furtokWrapper.innerHTML = `
|
furtokWrapper.innerHTML = `
|
||||||
<div class="furtok-header">
|
<div class="furtok-header">
|
||||||
<div class="furtok-title">🐺 FurTok</div>
|
<div class="furtok-title">🐺 FurTok</div>
|
||||||
<label class="furtok-toggle">
|
<div class="furtok-header-right">
|
||||||
<span>Safe</span>
|
<label class="furtok-toggle" id="furtok-safe-wrap" ${furtokCustomTags.trim() ? 'style="display:none"' : ''}>
|
||||||
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}>
|
<span>Safe</span>
|
||||||
</label>
|
<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>
|
||||||
<div class="furtok-feed" id="furtok-feed"></div>
|
<div class="furtok-feed" id="furtok-feed"></div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -531,39 +583,151 @@ function renderFurtok() {
|
||||||
|
|
||||||
const feed = document.getElementById('furtok-feed');
|
const feed = document.getElementById('furtok-feed');
|
||||||
const safeToggle = document.getElementById('furtok-safe');
|
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', () => {
|
safeToggle.addEventListener('change', () => {
|
||||||
furtokSafe = safeToggle.checked;
|
furtokSafe = safeToggle.checked;
|
||||||
furtokPage = 1;
|
_furtokReload(feed);
|
||||||
feed.innerHTML = '';
|
|
||||||
loadFurtokPage(feed);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Infinite scroll
|
// Gear button — toggle tags panel
|
||||||
feed.addEventListener('scroll', () => {
|
gearBtn.addEventListener('click', () => {
|
||||||
if (furtokLoading) return;
|
haptic();
|
||||||
if (feed.scrollTop + feed.clientHeight >= feed.scrollHeight - 200) {
|
const visible = tagsPanel.style.display !== 'none';
|
||||||
furtokPage++;
|
tagsPanel.style.display = visible ? 'none' : 'flex';
|
||||||
loadFurtokPage(feed);
|
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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Первая загрузка
|
|
||||||
loadFurtokPage(feed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFurtokPage(feedEl) {
|
async function loadFurtokPage(feedEl) {
|
||||||
if (furtokLoading) return;
|
if (furtokLoading) return;
|
||||||
furtokLoading = true;
|
furtokLoading = true;
|
||||||
|
|
||||||
// Лоадер
|
|
||||||
const loader = document.createElement('div');
|
const loader = document.createElement('div');
|
||||||
loader.className = 'furtok-loader';
|
loader.className = 'furtok-loader';
|
||||||
loader.textContent = '⏳ Загрузка...';
|
loader.textContent = '⏳ Загрузка...';
|
||||||
feedEl.appendChild(loader);
|
feedEl.appendChild(loader);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await API.getFurtokFeed(furtokSafe, furtokPage);
|
const res = await API.getFurtokFeed(furtokSafe, furtokPage, furtokCustomTags);
|
||||||
loader.remove();
|
loader.remove();
|
||||||
|
|
||||||
if (!res.feed || res.feed.length === 0) {
|
if (!res.feed || res.feed.length === 0) {
|
||||||
|
|
@ -579,15 +743,18 @@ async function loadFurtokPage(feedEl) {
|
||||||
card.className = 'furtok-card';
|
card.className = 'furtok-card';
|
||||||
|
|
||||||
let mediaHtml = '';
|
let mediaHtml = '';
|
||||||
if (post.ext === 'gif') {
|
if (post.type === 'image' || post.ext === 'gif') {
|
||||||
mediaHtml = `<img src="${post.url}" loading="lazy">`;
|
mediaHtml = `<img src="${post.url}" loading="lazy">`;
|
||||||
} else {
|
} else {
|
||||||
mediaHtml = `<video src="${post.url}" loop playsinline preload="metadata" muted></video>`;
|
mediaHtml = `<video src="${post.url}" loop playsinline preload="metadata" muted></video>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const caption = randomCaption();
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
${mediaHtml}
|
${mediaHtml}
|
||||||
<div class="furtok-overlay">
|
<div class="furtok-overlay">
|
||||||
|
<div class="furtok-caption">${caption}</div>
|
||||||
<div class="furtok-stats">
|
<div class="furtok-stats">
|
||||||
<span>⭐ ${post.score}</span>
|
<span>⭐ ${post.score}</span>
|
||||||
<span>❤️ ${post.fav_count}</span>
|
<span>❤️ ${post.fav_count}</span>
|
||||||
|
|
@ -606,10 +773,15 @@ async function loadFurtokPage(feedEl) {
|
||||||
}
|
}
|
||||||
|
|
||||||
feedEl.appendChild(card);
|
feedEl.appendChild(card);
|
||||||
|
furtokCards.push(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
// IntersectionObserver для автоплейа
|
// Автоплей первого видео при первой загрузке
|
||||||
setupAutoplay(feedEl);
|
if (furtokCurrentIndex === 0 && furtokCards.length > 0) {
|
||||||
|
const firstVideo = furtokCards[0].querySelector('video');
|
||||||
|
if (firstVideo) firstVideo.play().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
haptic('success');
|
haptic('success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loader.remove();
|
loader.remove();
|
||||||
|
|
@ -621,20 +793,6 @@ async function loadFurtokPage(feedEl) {
|
||||||
furtokLoading = false;
|
furtokLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupAutoplay(feedEl) {
|
|
||||||
const videos = feedEl.querySelectorAll('.furtok-card video');
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
entry.target.play().catch(() => {});
|
|
||||||
} else {
|
|
||||||
entry.target.pause();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, { root: feedEl, threshold: 0.6 });
|
|
||||||
|
|
||||||
videos.forEach(v => observer.observe(v));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════
|
// ══════════════════════════════════════════
|
||||||
// PAGE: CHAT
|
// PAGE: CHAT
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue