Merge pull request 'feature/FenyaBot' (#26) from Dan4ick/bot_tg:feature/FenyaBot into main

Reviewed-on: zovos/bot_tg#26
This commit is contained in:
q 2026-04-16 09:19:46 +00:00
commit 9216bde63a
11 changed files with 373 additions and 71 deletions

1
.gitignore vendored
View file

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

View file

@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
# Основные настройки поведения и стиля бота редактируются здесь.
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", "бот")
SKIP_TOKEN = "<skip>"
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}

View file

@ -59,7 +59,10 @@ def _init_bets_db() -> None:
conn.commit()
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:

View file

@ -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:

View file

@ -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()

View file

@ -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:
url = "https://e621.net/posts.json"
params = {
@ -116,8 +94,15 @@ async def fetch_uwu_post(tags: str) -> dict:
"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"
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"
@ -142,18 +127,24 @@ async def fetch_furtok_feed(safe: bool = True, page: int = 1) -> list[dict]:
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 ("gif", "webm", "mp4"):
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),
})

View file

@ -597,11 +597,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
@ -613,6 +616,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:
@ -621,12 +625,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)

View file

@ -8,12 +8,16 @@ 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
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.responses import Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
@ -389,17 +393,46 @@ async def get_uwu_api(
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)
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")

View file

@ -891,6 +891,8 @@ body::before {
overflow-y: scroll;
scroll-snap-type: y mandatory;
scrollbar-width: none;
overscroll-behavior: none;
touch-action: none;
}
.furtok-feed::-webkit-scrollbar { display: none; }
@ -911,9 +913,13 @@ body::before {
.furtok-card video,
.furtok-card img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.furtok-overlay {
@ -924,11 +930,21 @@ body::before {
padding: 16px;
background: linear-gradient(transparent, rgba(0,0,0,0.7));
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-direction: column;
gap: 8px;
align-items: flex-start;
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 {
display: flex;
gap: 16px;
@ -937,6 +953,7 @@ body::before {
color: rgba(255,255,255,0.85);
}
.furtok-header {
position: absolute;
top: 0;
@ -966,6 +983,31 @@ body::before {
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"] {
appearance: none;
-webkit-appearance: none;
@ -998,6 +1040,68 @@ body::before {
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 {
height: 100%;
scroll-snap-align: start;

View file

@ -41,6 +41,6 @@ export const API = {
getTop: () => api('/api/top'),
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`),
getFurtokFeed: (safe = true, page = 1) => api(`/api/furtok?safe=${safe}&page=${page}`),
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 } }),
};

View file

@ -504,26 +504,78 @@ 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 = '';
// Удаляем старый wrapper если есть
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>
<label class="furtok-toggle">
<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>
`;
@ -531,39 +583,151 @@ function renderFurtok() {
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;
furtokPage = 1;
feed.innerHTML = '';
loadFurtokPage(feed);
_furtokReload(feed);
});
// Infinite scroll
feed.addEventListener('scroll', () => {
if (furtokLoading) return;
if (feed.scrollTop + feed.clientHeight >= feed.scrollHeight - 200) {
// 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);
const res = await API.getFurtokFeed(furtokSafe, furtokPage, furtokCustomTags);
loader.remove();
if (!res.feed || res.feed.length === 0) {
@ -579,15 +743,18 @@ async function loadFurtokPage(feedEl) {
card.className = 'furtok-card';
let mediaHtml = '';
if (post.ext === 'gif') {
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>
@ -606,10 +773,15 @@ async function loadFurtokPage(feedEl) {
}
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');
} catch (e) {
loader.remove();
@ -621,20 +793,6 @@ async function loadFurtokPage(feedEl) {
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