From 3baf71ab98ccf95e72827c8e0fdeb263247374bd Mon Sep 17 00:00:00 2001 From: Omelechko Danil Date: Mon, 23 Mar 2026 17:07:12 +0300 Subject: [PATCH 01/30] fix: replace dead esports keys with football leagues, fix payout stats --- .env | 2 +- config.py | 14 +++++++++----- games/betting.py | 18 ++++++++++++++---- main.py | 5 +++-- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/.env b/.env index d863659..0322222 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ -BOT_TOKEN=8488665890:AAH7FY5I2xzHhfOdxlzb11ewl2I-MMTH2-o +BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc TZ=Europe/Moscow ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923 diff --git a/config.py b/config.py index 4e36f71..0345a0a 100644 --- a/config.py +++ b/config.py @@ -35,9 +35,13 @@ E621_API_KEY = os.getenv("E621_API_KEY", "") ODDS_API_KEY = os.getenv("ODDS_API_KEY", "") ODDS_API_BASE = "https://api.the-odds-api.com/v4" ODDS_SPORTS = { - "cs": "esports_csgo", - "dota": "esports_dota2", - "football": "soccer_epl", + "epl": "soccer_epl", + "ucl": "soccer_uefa_champs_league", + "laliga": "soccer_spain_la_liga", + "bundesliga": "soccer_germany_bundesliga", + "seriea": "soccer_italy_serie_a", + "ligue1": "soccer_france_ligue_one", + "europa": "soccer_uefa_europa_league", } BET_MAX_AMOUNT = 3.0 BET_DB_PATH = "/db/bets.sqlite3" @@ -209,8 +213,8 @@ def get_hint_text(templates_str: str) -> str: "• /talk [текст] — ИИ базарит на фене.\n" "• /penis_casino [ставка] — казино на размер.\n" "• /gadanie [тема] — гадание на фене с мемом.\n" - "• /matches [cs|dota|football] — матчи для ставок.\n" - "• /bet [спорт] [номер] [исход] [ставка] — поставить см.\n" + "• /matches [epl|ucl|laliga|bundesliga|seriea|ligue1|europa] — матчи для ставок.\n" + "• /bet [лига] [номер] [исход] [ставка] — поставить см.\n" "• /mybets — мои активные ставки.\n" "• /bet_history — история ставок за месяц + статистика.\n" "• /sports_debug — диагностика API матчей.\n" diff --git a/games/betting.py b/games/betting.py index cf22ceb..933da3c 100644 --- a/games/betting.py +++ b/games/betting.py @@ -264,7 +264,15 @@ def format_matches(matches: list[dict], sport_alias: str, sport_label: str) -> s def _get_sport_label(key: str) -> str: - labels = {"cs": "🎮 CS2", "dota": "🎮 Dota 2", "football": "⚽ Футбол"} + labels = { + "epl": "⚽ EPL", + "ucl": "⚽ Champions League", + "laliga": "⚽ La Liga", + "bundesliga": "⚽ Bundesliga", + "seriea": "⚽ Serie A", + "ligue1": "⚽ Ligue 1", + "europa": "⚽ Europa League", + } return labels.get(key, key) @@ -436,15 +444,17 @@ def get_user_bet_history(user_id: int) -> str: status = r["status"] amount = float(r["amount"]) odds_val = float(r["odds"]) - payout = float(r["payout"] or 0) + payout_raw = float(r["payout"] or 0) total_bet += amount if status == "won": wins += 1 - total_won += payout + # Для старых ставок payout может быть 0, пересчитываем + actual_payout = payout_raw if payout_raw > 0 else round(amount * odds_val, 1) + total_won += actual_payout status_icon = "✅" - result_text = f"+{payout:.1f} см" + result_text = f"+{actual_payout:.1f} см" elif status == "lost": losses += 1 total_lost += amount diff --git a/main.py b/main.py index 6e5fa5d..25a009b 100644 --- a/main.py +++ b/main.py @@ -1237,7 +1237,8 @@ async def handle_matches_cmd(message: Message): if sport_alias and sport_alias not in config.ODDS_SPORTS: if message.from_user: clear_user_sport_context(message.from_user.id) - await message.reply("Неизвестный спорт. Доступно: cs / dota / football", parse_mode=None) + available = " / ".join(config.ODDS_SPORTS.keys()) + await message.reply(f"Неизвестная лига. Доступно: {available}", parse_mode=None) return if message.from_user: if sport_alias and sport_alias in config.ODDS_SPORTS: @@ -1280,7 +1281,7 @@ async def handle_bet_cmd(message: Message): sport_alias = get_user_sport_context(message.from_user.id) if not sport_alias: await message.reply( - "Сначала открой /matches cs, /matches dota или /matches football.\n" + "Сначала открой /matches с нужной лигой (epl, ucl, laliga...).\n" "Либо пиши полный формат: /bet [спорт] [номер] [исход] [ставка]", parse_mode=None, ) From f0cefc7f239cb3ff9002cb460ede910a90421584 Mon Sep 17 00:00:00 2001 From: Omelechko Danil Date: Mon, 23 Mar 2026 17:13:09 +0300 Subject: [PATCH 02/30] fix: uwu gifs now sent as animation, caption as separate message --- games/uwu.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/games/uwu.py b/games/uwu.py index 0ad5108..634e4f1 100644 --- a/games/uwu.py +++ b/games/uwu.py @@ -68,15 +68,18 @@ async def handle_uwu_cmd(message: types.Message): await message.reply("У найденного поста нет прямого URL изображения :(") return - input_file = types.URLInputFile(file_url) caption = random.choice(CAPTIONS) - if ext in ("png", "jpg", "jpeg", "webp"): + if ext in ("gif", "webm", "mp4"): + # Для анимаций: отправляем caption отдельным сообщением, + # а саму гифку/видео через URL напрямую — так Telegram + # корректно показывает её как анимацию, а не как файл + await message.answer(caption) + await message.answer_animation(animation=file_url) + elif ext in ("png", "jpg", "jpeg", "webp"): + input_file = types.URLInputFile(file_url) await message.answer_photo(photo=input_file, caption=caption) - elif ext == "gif": - await message.answer_animation(animation=input_file, caption=caption) - elif ext in ("webm", "mp4"): - await message.answer_video(video=input_file, caption=caption) else: + input_file = types.URLInputFile(file_url) await message.answer_document(document=input_file, caption=caption) except Exception as e: From e014019912016ee5fbd41a40d279d75d8616d633 Mon Sep 17 00:00:00 2001 From: Omelechko Danil Date: Mon, 23 Mar 2026 18:19:04 +0300 Subject: [PATCH 03/30] perf: increase odds cache TTL to 24h to save API quota --- config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.py b/config.py index 0345a0a..1baafa3 100644 --- a/config.py +++ b/config.py @@ -45,7 +45,7 @@ ODDS_SPORTS = { } BET_MAX_AMOUNT = 3.0 BET_DB_PATH = "/db/bets.sqlite3" -ODDS_CACHE_TTL = 900 +ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно) # --- Magnit API --- MAGNIT_API_BASE_URL = os.getenv("MAGNIT_API_BASE_URL", "https://mirror.porno4free.ru/magnit") From 481965e8f284d672e3c58bb84e1c190dc89bc8bd Mon Sep 17 00:00:00 2001 From: Omelechko Danil Date: Mon, 23 Mar 2026 18:22:34 +0300 Subject: [PATCH 04/30] feat: /matches without args shows list of available leagues --- main.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 25a009b..7ee496c 100644 --- a/main.py +++ b/main.py @@ -1234,14 +1234,33 @@ async def handle_matches_cmd(message: Message): raw_text = message.text or "" parts = raw_text.split(maxsplit=1) sport_alias = parts[1].strip().lower() if len(parts) > 1 else None - if sport_alias and sport_alias not in config.ODDS_SPORTS: + + # Если лига не указана — показать список + if not sport_alias: + labels = { + "epl": "⚽ EPL (Англия)", + "ucl": "⚽ Champions League", + "laliga": "⚽ La Liga (Испания)", + "bundesliga": "⚽ Bundesliga (Германия)", + "seriea": "⚽ Serie A (Италия)", + "ligue1": "⚽ Ligue 1 (Франция)", + "europa": "⚽ Europa League", + } + lines = ["🏆 Доступные лиги для ставок:\n"] + for alias, name in labels.items(): + lines.append(f" {name} → /matches {alias}") + lines.append("\nПример: /matches epl") + await message.reply("\n".join(lines), parse_mode=None) + return + + if sport_alias not in config.ODDS_SPORTS: if message.from_user: clear_user_sport_context(message.from_user.id) available = " / ".join(config.ODDS_SPORTS.keys()) await message.reply(f"Неизвестная лига. Доступно: {available}", parse_mode=None) return if message.from_user: - if sport_alias and sport_alias in config.ODDS_SPORTS: + if sport_alias in config.ODDS_SPORTS: remember_user_sport_context(message.from_user.id, sport_alias) else: clear_user_sport_context(message.from_user.id) From c3487c22b4747c33601428a6a628cb9f32c73eb8 Mon Sep 17 00:00:00 2001 From: Omelechko Danil Date: Sat, 4 Apr 2026 23:03:17 +0300 Subject: [PATCH 05/30] feat: TG Mini App - FastAPI backend + frontend SPA + Docker setup --- .env | 2 +- Dockerfile.api | 18 + docker-compose.yaml | 27 ++ main.py | 12 +- webapp/__init__.py | 1 + webapp/api.py | 394 +++++++++++++++ webapp/auth.py | 82 ++++ webapp/frontend/Dockerfile | 4 + webapp/frontend/css/style.css | 873 ++++++++++++++++++++++++++++++++++ webapp/frontend/index.html | 42 ++ webapp/frontend/js/api.js | 44 ++ webapp/frontend/js/app.js | 554 +++++++++++++++++++++ webapp/frontend/nginx.conf | 26 + webapp/requirements-api.txt | 2 + 14 files changed, 2078 insertions(+), 3 deletions(-) create mode 100644 Dockerfile.api create mode 100644 webapp/__init__.py create mode 100644 webapp/api.py create mode 100644 webapp/auth.py create mode 100644 webapp/frontend/Dockerfile create mode 100644 webapp/frontend/css/style.css create mode 100644 webapp/frontend/index.html create mode 100644 webapp/frontend/js/api.js create mode 100644 webapp/frontend/js/app.js create mode 100644 webapp/frontend/nginx.conf create mode 100644 webapp/requirements-api.txt diff --git a/.env b/.env index 0322222..98f9cd2 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc TZ=Europe/Moscow -ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923 +ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923 \ No newline at end of file diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..b31a72b --- /dev/null +++ b/Dockerfile.api @@ -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"] diff --git a/docker-compose.yaml b/docker-compose.yaml index 7432aa9..3dd4ddd 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,5 +11,32 @@ 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 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" + restart: unless-stopped + + webapp-frontend: + build: + context: ./webapp/frontend + ports: + - "3000:80" + depends_on: + - webapp-api + restart: unless-stopped diff --git a/main.py b/main.py index 7ee496c..7c8c1a0 100644 --- a/main.py +++ b/main.py @@ -14,7 +14,8 @@ 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 @@ -948,7 +949,14 @@ 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: + kb = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎮 Открыть Mini App", web_app=WebAppInfo(url=webapp_url))] + ]) + await message.answer("Йо, кент! Жми кнопку 👇", reply_markup=kb) + else: + await message.answer("Соси") async def handle_schedule_cmd(message: Message): now = datetime.now(ZoneInfo(config.DEFAULT_TZ)) diff --git a/webapp/__init__.py b/webapp/__init__.py new file mode 100644 index 0000000..f62e193 --- /dev/null +++ b/webapp/__init__.py @@ -0,0 +1 @@ +# webapp package diff --git a/webapp/api.py b/webapp/api.py new file mode 100644 index 0000000..7ef872c --- /dev/null +++ b/webapp/api.py @@ -0,0 +1,394 @@ +""" +FenyaBot Mini App — FastAPI Backend. + +REST API для Telegram Mini App. Использует те же SQLite базы и +бизнес-логику что и основной бот. +""" + +import asyncio +import logging +import os +import sys +from contextlib import asynccontextmanager +from typing import Annotated + +from fastapi import FastAPI, Depends, Header, HTTPException +from fastapi.middleware.cors import CORSMiddleware +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_user_bets as _get_user_bets_raw, + get_user_bet_history as _get_user_bet_history_raw, +) +from webapp.auth import validate_init_data + +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): + # Извлекаем коэффициенты + odds_home = odds_draw = odds_away = None + bookmakers = m.get("bookmakers", []) + if bookmakers: + markets = bookmakers[0].get("markets", []) + for market in markets: + if market.get("key") == "h2h": + outcomes = market.get("outcomes", []) + for o in outcomes: + name = o.get("name", "") + price = o.get("price", 0) + if name == m.get("home_team"): + odds_home = price + elif name == m.get("away_team"): + odds_away = price + elif name == "Draw": + odds_draw = price + + result.append(MatchResponse( + index=i + 1, + home=m.get("home_team", "?"), + away=m.get("away_team", "?"), + commence=m.get("commence_time", ""), + 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"] + display_name = user["username"] or user["first_name"] + result = await place_bet(user_id, display_name, req.league, req.match_index, req.outcome, 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 _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 _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 + + +# --- ИИ Чат --- + +@app.post("/api/talk") +async def talk_api( + req: TalkRequest, + user: dict = Depends(get_current_user), +): + """Отправить сообщение ИИ.""" + from AI.talk_handler import _generate_response, push_message + + user_id = user["user_id"] + user_name = user["first_name"] or "кент" + + # Используем user_id как chat_id для личных сообщений в webapp + chat_id = user_id + + try: + response = await _generate_response(chat_id, req.text, user_name) + if not response: + response = "Братуха, чёт базар не клеится." + await asyncio.to_thread(push_message, chat_id, "user", user_name, req.text) + await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response) + return {"response": response} + except Exception: + logger.exception("Talk API failed") + raise HTTPException(status_code=500, detail="LLM error") + + +# ──────────────────── Entry point ──────────────────── + +if __name__ == "__main__": + import uvicorn + port = int(os.getenv("API_PORT", "8080")) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/webapp/auth.py b/webapp/auth.py new file mode 100644 index 0000000..8b0c43a --- /dev/null +++ b/webapp/auth.py @@ -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 diff --git a/webapp/frontend/Dockerfile b/webapp/frontend/Dockerfile new file mode 100644 index 0000000..01bb247 --- /dev/null +++ b/webapp/frontend/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:alpine +COPY . /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/webapp/frontend/css/style.css b/webapp/frontend/css/style.css new file mode 100644 index 0000000..c227cd1 --- /dev/null +++ b/webapp/frontend/css/style.css @@ -0,0 +1,873 @@ +/* ═══════════════════════════════════════════ + FenyaBot Mini App — Design System + Theme: Blue-Black Glassmorphism + ═══════════════════════════════════════════ */ + +:root { + --bg-primary: #060a14; + --bg-secondary: #0c1222; + --bg-card: rgba(12, 22, 45, 0.75); + --bg-card-hover: rgba(18, 32, 60, 0.85); + --bg-nav: rgba(8, 14, 30, 0.92); + + --blue-500: #3b82f6; + --blue-400: #60a5fa; + --blue-300: #93c5fd; + --blue-600: #2563eb; + --blue-700: #1d4ed8; + --blue-900: #1e3a8a; + + --green-400: #4ade80; + --green-500: #22c55e; + --red-400: #f87171; + --red-500: #ef4444; + --yellow-400: #facc15; + --orange-400: #fb923c; + + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #64748b; + + --gradient-primary: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%); + --gradient-accent: linear-gradient(135deg, #2563eb 0%, #60a5fa 100%); + --gradient-card: linear-gradient(145deg, rgba(15,25,50,0.8) 0%, rgba(8,16,35,0.9) 100%); + --gradient-casino: linear-gradient(135deg, #7c3aed 0%, #3b82f6 100%); + + --border-color: rgba(59, 130, 246, 0.15); + --border-glow: rgba(59, 130, 246, 0.3); + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; + + --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 1px rgba(59, 130, 246, 0.1); + --shadow-glow: 0 0 20px rgba(59, 130, 246, 0.15); + --shadow-btn: 0 4px 16px rgba(59, 130, 246, 0.3); + + --nav-height: 64px; + --safe-bottom: env(safe-area-inset-bottom, 0px); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +body::before { + content: ''; + position: fixed; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle at 30% 20%, rgba(30, 58, 138, 0.15) 0%, transparent 50%), + radial-gradient(circle at 70% 80%, rgba(59, 130, 246, 0.08) 0%, transparent 40%); + z-index: -1; + pointer-events: none; +} + +#app { + max-width: 480px; + margin: 0 auto; + min-height: 100vh; + position: relative; +} + +#page-container { + padding: 16px 16px calc(var(--nav-height) + var(--safe-bottom) + 16px); + min-height: 100vh; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +/* ── Bottom Nav ── */ + +#bottom-nav { + position: fixed; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 480px; + display: flex; + justify-content: space-around; + align-items: center; + height: var(--nav-height); + padding-bottom: var(--safe-bottom); + background: var(--bg-nav); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-top: 1px solid var(--border-color); + z-index: 100; +} + +.nav-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 6px 0; + transition: all 0.2s ease; + -webkit-tap-highlight-color: transparent; + flex: 1; +} + +.nav-btn.active { + color: var(--blue-400); +} + +.nav-btn:active { + transform: scale(0.92); +} + +.nav-icon { + font-size: 22px; + line-height: 1; +} + +.nav-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; +} + +/* ── Cards ── */ + +.card { + background: var(--gradient-card); + backdrop-filter: blur(12px); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 16px; + margin-bottom: 12px; + box-shadow: var(--shadow-card); + transition: transform 0.2s ease, box-shadow 0.2s ease; + animation: slideUp 0.4s ease backwards; +} + +.card:active { + transform: scale(0.98); +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.card-title { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); +} + +.card-subtitle { + font-size: 12px; + color: var(--text-secondary); +} + +/* ── Profile Card ── */ + +.profile-card { + background: var(--gradient-primary); + border: none; + padding: 24px; + text-align: center; + position: relative; + overflow: hidden; +} + +.profile-card::after { + content: ''; + position: absolute; + top: -50%; + right: -50%; + width: 100%; + height: 100%; + background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); + pointer-events: none; +} + +.profile-avatar { + width: 64px; + height: 64px; + border-radius: var(--radius-full); + background: rgba(255,255,255,0.15); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + margin: 0 auto 12px; + border: 2px solid rgba(255,255,255,0.2); +} + +.profile-name { + font-size: 20px; + font-weight: 800; + margin-bottom: 4px; +} + +.profile-balance { + font-size: 32px; + font-weight: 800; + margin: 12px 0 4px; + text-shadow: 0 2px 8px rgba(0,0,0,0.3); +} + +.profile-balance-label { + font-size: 13px; + opacity: 0.7; + font-weight: 500; +} + +/* ── Stat Grid ── */ + +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 12px; +} + +.stat-item { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 14px; + text-align: center; +} + +.stat-value { + font-size: 22px; + font-weight: 800; + color: var(--blue-400); +} + +.stat-label { + font-size: 11px; + color: var(--text-secondary); + margin-top: 4px; + font-weight: 500; +} + +/* ── Buttons ── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 24px; + border-radius: var(--radius-md); + border: none; + font-family: 'Inter', sans-serif; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + -webkit-tap-highlight-color: transparent; + width: 100%; +} + +.btn:active { + transform: scale(0.96); +} + +.btn-primary { + background: var(--gradient-accent); + color: white; + box-shadow: var(--shadow-btn); +} + +.btn-primary:hover { + box-shadow: 0 6px 24px rgba(59, 130, 246, 0.4); +} + +.btn-secondary { + background: rgba(59, 130, 246, 0.12); + color: var(--blue-400); + border: 1px solid var(--border-color); +} + +.btn-danger { + background: rgba(239, 68, 68, 0.15); + color: var(--red-400); + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.btn-sm { + padding: 8px 16px; + font-size: 12px; + border-radius: var(--radius-sm); +} + +.btn-lg { + padding: 16px 32px; + font-size: 16px; + border-radius: var(--radius-lg); +} + +/* ── Quick Actions ── */ + +.quick-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.quick-action { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 16px 12px; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; + color: var(--text-primary); + font-family: 'Inter', sans-serif; +} + +.quick-action:active { + transform: scale(0.95); + background: var(--bg-card-hover); +} + +.quick-action-icon { + font-size: 28px; + margin-bottom: 6px; +} + +.quick-action-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + +/* ── Match Card ── */ + +.match-card { + padding: 14px; +} + +.match-teams { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.match-team { + flex: 1; + text-align: center; + font-size: 13px; + font-weight: 600; +} + +.match-vs { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + padding: 0 8px; +} + +.match-time { + text-align: center; + font-size: 11px; + color: var(--text-muted); + margin-bottom: 10px; +} + +.match-odds { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 6px; +} + +.odds-btn { + background: rgba(59, 130, 246, 0.08); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 8px; + text-align: center; + cursor: pointer; + transition: all 0.15s ease; + color: var(--text-primary); + font-family: 'Inter', sans-serif; +} + +.odds-btn:hover, .odds-btn.selected { + background: rgba(59, 130, 246, 0.2); + border-color: var(--blue-500); + box-shadow: 0 0 8px rgba(59, 130, 246, 0.2); +} + +.odds-label { + font-size: 10px; + color: var(--text-muted); + display: block; +} + +.odds-value { + font-size: 15px; + font-weight: 700; + color: var(--blue-400); + display: block; + margin-top: 2px; +} + +/* ── Casino / Slots ── */ + +.slots-display { + display: flex; + justify-content: center; + gap: 12px; + margin: 24px 0; +} + +.slot-reel { + width: 72px; + height: 72px; + background: var(--bg-secondary); + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 36px; + transition: all 0.3s ease; +} + +.slot-reel.spinning { + animation: spin 0.3s linear infinite; +} + +.slot-reel.won { + border-color: var(--green-400); + box-shadow: 0 0 16px rgba(74, 222, 128, 0.3); +} + +.casino-bet-input { + display: flex; + align-items: center; + gap: 10px; + margin: 16px 0; +} + +.casino-bet-input input { + flex: 1; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 12px 16px; + color: var(--text-primary); + font-family: 'Inter', sans-serif; + font-size: 16px; + font-weight: 600; + text-align: center; + outline: none; + transition: border-color 0.2s; +} + +.casino-bet-input input:focus { + border-color: var(--blue-500); +} + +/* ── Schedule ── */ + +.schedule-day-tabs { + display: flex; + gap: 6px; + overflow-x: auto; + padding-bottom: 8px; + margin-bottom: 12px; + scrollbar-width: none; +} + +.schedule-day-tabs::-webkit-scrollbar { display: none; } + +.day-tab { + flex-shrink: 0; + padding: 8px 14px; + border-radius: var(--radius-full); + background: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + font-family: 'Inter', sans-serif; +} + +.day-tab.active { + background: var(--gradient-accent); + color: white; + border-color: transparent; +} + +.lesson-item { + display: flex; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid rgba(59, 130, 246, 0.06); +} + +.lesson-item:last-child { + border-bottom: none; +} + +.lesson-number { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + background: rgba(59, 130, 246, 0.1); + color: var(--blue-400); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + flex-shrink: 0; +} + +.lesson-info { + flex: 1; +} + +.lesson-title { + font-size: 13px; + font-weight: 600; + margin-bottom: 2px; +} + +.lesson-time { + font-size: 11px; + color: var(--text-muted); +} + +/* ── Chat ── */ + +.chat-messages { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 72px; + min-height: 50vh; +} + +.chat-bubble { + max-width: 85%; + padding: 10px 14px; + border-radius: var(--radius-lg); + font-size: 14px; + line-height: 1.4; + animation: slideUp 0.2s ease; +} + +.chat-bubble.user { + align-self: flex-end; + background: var(--gradient-accent); + color: white; + border-bottom-right-radius: 4px; +} + +.chat-bubble.bot { + align-self: flex-start; + background: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-primary); + border-bottom-left-radius: 4px; +} + +.chat-input-bar { + position: fixed; + bottom: var(--nav-height); + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 480px; + display: flex; + gap: 8px; + padding: 8px 16px; + background: var(--bg-nav); + backdrop-filter: blur(20px); + border-top: 1px solid var(--border-color); +} + +.chat-input-bar input { + flex: 1; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-full); + padding: 10px 16px; + color: var(--text-primary); + font-family: 'Inter', sans-serif; + font-size: 14px; + outline: none; +} + +.chat-input-bar input:focus { + border-color: var(--blue-500); +} + +.chat-send-btn { + width: 40px; + height: 40px; + border-radius: var(--radius-full); + background: var(--gradient-accent); + border: none; + color: white; + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.15s; +} + +.chat-send-btn:active { + transform: scale(0.9); +} + +/* ── League List ── */ + +.league-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.league-item { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s; + font-family: 'Inter', sans-serif; + color: var(--text-primary); +} + +.league-item:active { + transform: scale(0.97); + background: var(--bg-card-hover); +} + +.league-icon { + font-size: 24px; +} + +.league-name { + font-size: 14px; + font-weight: 600; +} + +.league-arrow { + margin-left: auto; + color: var(--text-muted); +} + +/* ── Bet Slip ── */ + +.bet-slip { + position: fixed; + bottom: var(--nav-height); + left: 50%; + transform: translateX(-50%) translateY(100%); + width: 100%; + max-width: 480px; + background: var(--bg-nav); + backdrop-filter: blur(20px); + border-top: 1px solid var(--border-glow); + padding: 16px; + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); + z-index: 50; +} + +.bet-slip.visible { + transform: translateX(-50%) translateY(0); +} + +.bet-slip-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.bet-input { + width: 100%; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 12px 16px; + color: var(--text-primary); + font-family: 'Inter', sans-serif; + font-size: 16px; + font-weight: 600; + text-align: center; + outline: none; + margin-bottom: 10px; +} + +.bet-input:focus { + border-color: var(--blue-500); +} + +/* ── Loading skeleton ── */ + +.skeleton { + background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-card-hover) 50%, var(--bg-card) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: var(--radius-md); +} + +.skeleton-line { + height: 14px; + margin-bottom: 8px; + border-radius: 4px; +} + +.skeleton-circle { + width: 64px; + height: 64px; + border-radius: var(--radius-full); + margin: 0 auto 12px; +} + +/* ── Section header ── */ + +.section-header { + font-size: 18px; + font-weight: 700; + margin-bottom: 12px; + padding-left: 4px; +} + +/* ── Badge ── */ + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: var(--radius-full); + font-size: 11px; + font-weight: 600; +} + +.badge-green { + background: rgba(34, 197, 94, 0.15); + color: var(--green-400); +} + +.badge-red { + background: rgba(239, 68, 68, 0.15); + color: var(--red-400); +} + +.badge-blue { + background: rgba(59, 130, 246, 0.15); + color: var(--blue-400); +} + +/* ── Result popup ── */ + +.result-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + animation: fadeIn 0.2s ease; +} + +.result-popup { + background: var(--bg-secondary); + border: 1px solid var(--border-glow); + border-radius: var(--radius-xl); + padding: 32px 24px; + text-align: center; + max-width: 320px; + width: 90%; + animation: slideUp 0.3s ease; +} + +.result-icon { + font-size: 48px; + margin-bottom: 12px; +} + +.result-title { + font-size: 20px; + font-weight: 800; + margin-bottom: 8px; +} + +.result-desc { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 20px; + line-height: 1.5; +} + +/* ── Empty state ── */ + +.empty-state { + text-align: center; + padding: 48px 16px; + color: var(--text-muted); +} + +.empty-state-icon { + font-size: 48px; + margin-bottom: 12px; +} + +.empty-state-text { + font-size: 14px; + line-height: 1.5; +} diff --git a/webapp/frontend/index.html b/webapp/frontend/index.html new file mode 100644 index 0000000..b88bcc0 --- /dev/null +++ b/webapp/frontend/index.html @@ -0,0 +1,42 @@ + + + + + + + FenyaBot + + + + + + + +
+
+ +
+ + + diff --git a/webapp/frontend/js/api.js b/webapp/frontend/js/api.js new file mode 100644 index 0000000..5933f1a --- /dev/null +++ b/webapp/frontend/js/api.js @@ -0,0 +1,44 @@ +/** + * 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}` : ''}`), + talk: (text) => api('/api/talk', { method: 'POST', body: { text } }), +}; diff --git a/webapp/frontend/js/app.js b/webapp/frontend/js/app.js new file mode 100644 index 0000000..018d01f --- /dev/null +++ b/webapp/frontend/js/app.js @@ -0,0 +1,554 @@ +/** + * 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(); + 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 '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 = $(` +
+
+
${icon}
+
${title}
+
${desc}
+ +
+
+ `); + 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 `
${'
'.repeat(lines)}
`; +} + +// ══════════════════════════════════════════ +// PAGE: HOME +// ══════════════════════════════════════════ + +async function renderHome() { + container.innerHTML = ` +
+
+
+
+
+
+
+
+
+ `; + + try { + profileData = await API.getProfile(); + const p = profileData; + const length = p.length != null ? p.length.toFixed(1) : '—'; + + container.innerHTML = ` +
+
👤
+
${p.first_name || p.username || 'Кент'}
+
${length} см
+
текущий размер
+
+ +
+
+
${p.active_bets}
+
Активных ставок
+
+
+
🎲
+
Крутить penis
+
+
+ +
Быстрые действия
+
+ + + + +
+ `; + + // Крутить 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 = ` +
+
⚠️
+
Не удалось загрузить профиль
${e.message}
+
+ `; + } +} + +async function showTop() { + try { + const data = await API.getTop(); + await showResult('🏆', 'Лидерборд', data.text.replace(/\n/g, '
')); + } catch (e) { + await showResult('❌', 'Ошибка', e.message); + } +} + +async function showBetHistory() { + try { + const data = await API.getBetHistory(); + await showResult('📊', 'История ставок', data.text.replace(/\n/g, '
')); + } catch (e) { + await showResult('❌', 'Ошибка', e.message); + } +} + +// ══════════════════════════════════════════ +// PAGE: BETTING +// ══════════════════════════════════════════ + +async function renderBetting(data) { + if (data?.league) { + return renderMatches(data.league, data.leagueName); + } + + container.innerHTML = `
⚽ Ставки на матчи
${skeleton(2)}`; + + try { + const leagues = await API.getLeagues(); + container.innerHTML = ` +
⚽ Выбери лигу
+
+ `; + const list = document.getElementById('league-list'); + leagues.forEach(l => { + const item = $(` +
+ + ${l.name} + +
+ `); + item.addEventListener('click', () => { + haptic(); + navigate('betting', { league: l.alias, leagueName: l.name }); + }); + list.appendChild(item); + }); + } catch (e) { + container.innerHTML = `
${e.message}
`; + } +} + +async function renderMatches(league, leagueName) { + container.innerHTML = ` +
+ +
${leagueName}
+
+ ${skeleton(3)} + `; + document.getElementById('back-btn').onclick = () => navigate('betting'); + + try { + const matches = await API.getMatches(league); + if (!matches.length) { + container.innerHTML += `
📭
Нет матчей в этой лиге
`; + 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 = $(` +
+
${date}
+
+
${m.home}
+
VS
+
${m.away}
+
+
+ + + +
+
+ `); + + 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 += `
${e.message}
`; + } +} + +function showBetSlip() { + if (!selectedBet) return; + document.querySelector('.bet-slip')?.remove(); + + const outcomeLabel = { '1': `П1 (${selectedBet.home})`, 'X': 'Ничья', '2': `П2 (${selectedBet.away})` }; + const slip = $(` +
+
+ ${outcomeLabel[selectedBet.outcome]} + x${selectedBet.odds?.toFixed(2) || '?'} +
+ + +
+ `); + + 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 = ` +
🎰 Казино
+
+
Баланс
+
${balance} см
+
+ +
+
+
🎰
+
🎰
+
🎰
+
+
+ + см +
+ +
+ `; + + 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 = ` +
📅 Расписание
+
+ ${days.map((d, i) => ``).join('')} +
+
${skeleton(4)}
+ `; + + 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 = `
🎉
Нет пар!
`; + return; + } + lc.innerHTML = '
' + lessons.map(l => ` +
+
${l.pair || '—'}
+
+
${l.title}
+
${l.time_start && l.time_end ? `${l.time_start} — ${l.time_end}` : ''}
+
+
+ `).join('') + '
'; + } catch (e) { + lc.innerHTML = `
${e.message}
`; + } + } + + 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: CHAT +// ══════════════════════════════════════════ + +function renderChat() { + container.innerHTML = ` +
💬 ИИ на фене
+
+ ${chatHistory.length === 0 ? ` +
Йо, кент! Базарь, я тут. Пиши чё надо, братуха 🤙
+ ` : chatHistory.map(m => `
${m.text}
`).join('')} +
+
+ + +
+ `; + + 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 = $(`
${text}
`); + messages.appendChild(userBubble); + messages.scrollTop = messages.scrollHeight; + + // Loading + const loading = $(`
💭 печатает...
`); + 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 = $(`
${res.response}
`); + messages.appendChild(botBubble); + messages.scrollTop = messages.scrollHeight; + } catch (e) { + loading.remove(); + const errBubble = $(`
Ошибка: ${e.message}
`); + messages.appendChild(errBubble); + } + } + + sendBtn.addEventListener('click', send); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); }); + input.focus(); +} + +// ── Init ── +navigate('home'); diff --git a/webapp/frontend/nginx.conf b/webapp/frontend/nginx.conf new file mode 100644 index 0000000..0f3c80f --- /dev/null +++ b/webapp/frontend/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to backend + location /api/ { + proxy_pass http://webapp-api:8080; + 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-Telegram-Init-Data $http_x_telegram_init_data; + } + + # Cache static assets + location ~* \.(css|js|png|jpg|svg|woff2?)$ { + expires 7d; + add_header Cache-Control "public, immutable"; + } +} diff --git a/webapp/requirements-api.txt b/webapp/requirements-api.txt new file mode 100644 index 0000000..da83eb2 --- /dev/null +++ b/webapp/requirements-api.txt @@ -0,0 +1,2 @@ +fastapi>=0.111.0 +uvicorn[standard]>=0.29.0 From 78dc7f4810359188969fbf7421effbbd7583c597 Mon Sep 17 00:00:00 2001 From: itexpert228 <67105314+fdaser1337@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:47:56 +0300 Subject: [PATCH 06/30] pisi i sisi --- games/nude.py | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++ main.py | 3 ++ 2 files changed, 87 insertions(+) create mode 100644 games/nude.py diff --git a/games/nude.py b/games/nude.py new file mode 100644 index 0000000..5dfe692 --- /dev/null +++ b/games/nude.py @@ -0,0 +1,84 @@ +import aiohttp +from aiogram import types +import config +import logging +import random +import json + +logger = logging.getLogger(__name__) + +CAPTIONS = [ + "Hot stuff 🔥", + "Sexy content 😏" +] + +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 = ( + "🔥 Справка по команде /nude:\n\n" + "Получает случайные изображения взрослых женщин из открытых источников.\n" + "• Использует Pornhub API для поиска контента\n" + "• По умолчанию ищет популярные видео с высоким рейтингом\n" + "• Отправляет превью видео как изображение\n\n" + "Пример: /nude\n" + "По умолчанию: Популярный контент для взрослых" + ) + await message.reply(help_msg, parse_mode="HTML") + return + + # Используем Pornhub API для поиска видео + url = "https://www.pornhub.com/webmasters/search" + + params = { + "category": "straight", + "ordering": "mostviewed", + "period": "week", + "thumbsize": "medium" + } + + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } + + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params, headers=headers) as resp: + if resp.status != 200: + logger.error(f"Pornhub API Error: {resp.status}") + await message.reply("Не удалось получить контент от сервера :(") + return + + # Pornhub API возвращает HTML, нужно парсить + text = await resp.text() + + # Ищем URL превью в HTML + import re + thumb_pattern = r']+src="([^"]+)"[^>]+class="thumb"' + matches = re.findall(thumb_pattern, text) + + if not matches: + await message.reply("Не удалось найти контент :(") + return + + # Берем случайное превью + thumb_url = random.choice(matches) + + # Убедимся что URL полный + if not thumb_url.startswith('http'): + thumb_url = f"https://thumb.pornhub.com{thumb_url}" + + caption = random.choice(CAPTIONS) + + # Отправляем как фото + try: + input_file = types.URLInputFile(thumb_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{thumb_url}") + + except Exception as e: + logger.exception("Error in nude command") + await message.reply("Произошла ошибка при обращении к API :(") diff --git a/main.py b/main.py index 7ee496c..7891bde 100644 --- a/main.py +++ b/main.py @@ -23,6 +23,7 @@ from AI.talk_handler import handle_talk, generate_autoreply, push_message from games.casino import play_casino from games.fortune import generate_fortune from games.uwu import handle_uwu_cmd +from games.nude import handle_nude_cmd from games.betting import ( clear_user_sport_context, debug_sports, @@ -1417,6 +1418,7 @@ def main(): dp.message.register(handle_sports_debug_cmd, Command("sports_debug")) 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_keywords, F.text) async def on_startup(bot: Bot): @@ -1446,6 +1448,7 @@ def main(): BotCommand(command="sports_debug", description="диагностика API матчей"), BotCommand(command="svodka", description="СВО: итоги"), BotCommand(command="uwu", description="Случайная картинка с e621"), + BotCommand(command="nude", description="Голые женщины из открытых источников"), ] scopes = ( BotCommandScopeDefault(), From 6433d9c11e4ec5f007e03d7150662aa95a4ddb0b Mon Sep 17 00:00:00 2001 From: itexpert228 <67105314+fdaser1337@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:56:19 +0300 Subject: [PATCH 07/30] suki --- games/nude.py | 104 +++++++++++++++++++++++++++++++------------------- 1 file changed, 64 insertions(+), 40 deletions(-) diff --git a/games/nude.py b/games/nude.py index 5dfe692..280bbf4 100644 --- a/games/nude.py +++ b/games/nude.py @@ -3,7 +3,7 @@ from aiogram import types import config import logging import random -import json +import re logger = logging.getLogger(__name__) @@ -19,66 +19,90 @@ async def handle_nude_cmd(message: types.Message): help_msg = ( "🔥 Справка по команде /nude:\n\n" "Получает случайные изображения взрослых женщин из открытых источников.\n" - "• Использует Pornhub API для поиска контента\n" - "• По умолчанию ищет популярные видео с высоким рейтингом\n" - "• Отправляет превью видео как изображение\n\n" + "• Использует прямое сканирование сайтов для поиска контента\n" + "• По умолчанию ищет популярные изображения\n" + "• Отправляет изображения напрямую\n\n" "Пример: /nude\n" - "По умолчанию: Популярный контент для взрослых" + "По умолчанию: Случайные изображения с популярных сайтов" ) await message.reply(help_msg, parse_mode="HTML") return - # Используем Pornhub API для поиска видео - url = "https://www.pornhub.com/webmasters/search" + # Список сайтов для сканирования + sites = [ + "https://www.imagefap.com/gallery.php?pg=random", + "https://xhamster.com/photos?sort=random", + "https://spankbang.com/random" + ] - params = { - "category": "straight", - "ordering": "mostviewed", - "period": "week", - "thumbsize": "medium" - } + random_site = random.choice(sites) headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1" } try: async with aiohttp.ClientSession() as session: - async with session.get(url, params=params, headers=headers) as resp: + async with session.get(random_site, headers=headers) as resp: if resp.status != 200: - logger.error(f"Pornhub API Error: {resp.status}") - await message.reply("Не удалось получить контент от сервера :(") + logger.error(f"Site access error: {resp.status}") + await message.reply("Не удалось получить доступ к сайту :(") return - # Pornhub API возвращает HTML, нужно парсить - text = await resp.text() + html = await resp.text() - # Ищем URL превью в HTML - import re - thumb_pattern = r']+src="([^"]+)"[^>]+class="thumb"' - matches = re.findall(thumb_pattern, text) + # Ищем URL изображений в HTML + img_patterns = [ + r']+src="([^"]+)"', + r']+href="([^"]+\.(jpg|jpeg|png|gif|webp))"', + r'data-src="([^"]+)"' + ] - if not matches: - await message.reply("Не удалось найти контент :(") + img_urls = [] + for pattern in img_patterns: + matches = re.findall(pattern, html, re.IGNORECASE) + img_urls.extend(matches) + + if not img_urls: + await message.reply("Изображения не найдены на странице :(") return - # Берем случайное превью - thumb_url = random.choice(matches) + # Фильтруем и выбираем случайное изображение + 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 + + if any(ext in img_url.lower() for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']): + valid_imgs.append(img_url) - # Убедимся что URL полный - if not thumb_url.startswith('http'): - thumb_url = f"https://thumb.pornhub.com{thumb_url}" + if not valid_imgs: + await message.reply("Подходящие изображения не найдены :(") + return - caption = random.choice(CAPTIONS) - - # Отправляем как фото - try: - input_file = types.URLInputFile(thumb_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{thumb_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: logger.exception("Error in nude command") - await message.reply("Произошла ошибка при обращении к API :(") + await message.reply("Произошла ошибка при обработке запроса :(") From 7afd8ca21644fc36de8de6f641c25e12b5fa17d7 Mon Sep 17 00:00:00 2001 From: q Date: Fri, 10 Apr 2026 11:17:45 +0300 Subject: [PATCH 08/30] some fix for nude --- games/nude.py | 227 +++++++++++++++++++++++++++++++------------------- 1 file changed, 139 insertions(+), 88 deletions(-) diff --git a/games/nude.py b/games/nude.py index 280bbf4..71ac438 100644 --- a/games/nude.py +++ b/games/nude.py @@ -1,108 +1,159 @@ -import aiohttp -from aiogram import types -import config import logging import random -import re +from pathlib import PurePosixPath +from urllib.parse import urlparse + +import aiohttp +from aiogram import types + logger = logging.getLogger(__name__) CAPTIONS = [ "Hot stuff 🔥", - "Sexy content 😏" + "Sexy content 😏", ] +LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts" +LESAINT_REFERER = "https://lesaintdesseins.fr/" +LATEST_POSTS_LIMIT = 30 +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, +} + + +def _extract_photo_candidates(posts: list[dict]) -> list[dict]: + candidates = [] + + 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: + continue + + 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 + + +async def _fetch_latest_photo_candidates(session: aiohttp.ClientSession) -> list[dict]: + params = { + "per_page": str(LATEST_POSTS_LIMIT), + "orderby": "date", + "order": "desc", + "_embed": "wp:featuredmedia", + } + + async with session.get(LESAINT_POSTS_API, params=params, headers=REQUEST_HEADERS) as resp: + if resp.status != 200: + body = await resp.text() + raise RuntimeError(f"API returned {resp.status}: {body[:300]}") + + posts = await resp.json(content_type=None) + + if not isinstance(posts, list): + raise RuntimeError("Unexpected API response format") + + return _extract_photo_candidates(posts) + + +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 = ( "🔥 Справка по команде /nude:\n\n" - "Получает случайные изображения взрослых женщин из открытых источников.\n" - "• Использует прямое сканирование сайтов для поиска контента\n" - "• По умолчанию ищет популярные изображения\n" - "• Отправляет изображения напрямую\n\n" - "Пример: /nude\n" - "По умолчанию: Случайные изображения с популярных сайтов" + "Берет случайную фотку из последних постов сайта le saint des seins.\n" + "• Источник: https://lesaintdesseins.fr/\n" + "• Достает последние посты через WordPress REST API\n" + "• Выбирает случайную картинку и отправляет ее прямо в Telegram\n\n" + "Пример: /nude" ) 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=30) + 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 - - html = await resp.text() - - # Ищем URL изображений в HTML - img_patterns = [ - r']+src="([^"]+)"', - r']+href="([^"]+\.(jpg|jpeg|png|gif|webp))"', - r'data-src="([^"]+)"' - ] - - img_urls = [] - for pattern in img_patterns: - matches = re.findall(pattern, html, re.IGNORECASE) - img_urls.extend(matches) - - if not img_urls: - await message.reply("Изображения не найдены на странице :(") - return - - # Фильтруем и выбираем случайное изображение - 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 - - if any(ext in img_url.lower() for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']): - valid_imgs.append(img_url) - - if not valid_imgs: - await message.reply("Подходящие изображения не найдены :(") - return - - 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: + async with aiohttp.ClientSession(timeout=timeout) as session: + candidates = await _fetch_latest_photo_candidates(session) + + if not candidates: + await message.reply("Не удалось найти свежие фотки :(") + return + + selected = random.choice(candidates) + caption = random.choice(CAPTIONS) + image_bytes, filename = await _download_photo(session, selected["image_url"]) + + if image_bytes: + photo = types.BufferedInputFile(image_bytes, filename=filename) + await message.answer_photo(photo=photo, caption=caption) + return + + try: + photo = types.URLInputFile(selected["image_url"]) + await message.answer_photo(photo=photo, caption=caption) + except Exception: + logger.exception("Failed to send photo by URL") + await message.answer( + f"{caption}\n{selected['image_url']}\n{selected['post_url']}" + ) + + except Exception: logger.exception("Error in nude command") - await message.reply("Произошла ошибка при обработке запроса :(") + await message.reply("Произошла ошибка при получении фото :(") From 53b6fa72ae9242d124ae73eb419228ba1b3df04c Mon Sep 17 00:00:00 2001 From: q Date: Fri, 10 Apr 2026 11:20:14 +0300 Subject: [PATCH 09/30] some fix for nude change word --- games/nude.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/games/nude.py b/games/nude.py index 71ac438..0e0a0fd 100644 --- a/games/nude.py +++ b/games/nude.py @@ -10,8 +10,9 @@ from aiogram import types logger = logging.getLogger(__name__) CAPTIONS = [ - "Hot stuff 🔥", - "Sexy content 😏", + "Шо ты блять псюнчик решил свой подергать?", + "Решил подрочить сука?", + "гнойный писюн у тебя кнч, да", ] LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts" From eeed9af5451f6de0e00a8559a5f7bf42082e04ee Mon Sep 17 00:00:00 2001 From: q Date: Fri, 10 Apr 2026 12:01:20 +0300 Subject: [PATCH 10/30] some fix for nude save db with history with pic --- games/nude.py | 145 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 136 insertions(+), 9 deletions(-) diff --git a/games/nude.py b/games/nude.py index 0e0a0fd..4c37767 100644 --- a/games/nude.py +++ b/games/nude.py @@ -1,3 +1,5 @@ +import asyncio +import json import logging import random from pathlib import PurePosixPath @@ -6,6 +8,8 @@ from urllib.parse import urlparse import aiohttp from aiogram import types +import config + logger = logging.getLogger(__name__) @@ -17,7 +21,6 @@ CAPTIONS = [ LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts" LESAINT_REFERER = "https://lesaintdesseins.fr/" -LATEST_POSTS_LIMIT = 30 REQUEST_HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " @@ -29,9 +32,12 @@ REQUEST_HEADERS = { "Referer": LESAINT_REFERER, } +_NUDE_HISTORY_LOCK = asyncio.Lock() + def _extract_photo_candidates(posts: list[dict]) -> list[dict]: candidates = [] + seen_urls = set() for post in posts: embedded = post.get("_embedded") or {} @@ -48,9 +54,10 @@ def _extract_photo_candidates(posts: list[dict]) -> list[dict]: or media.get("source_url") ) - if not image_url: + if not image_url or image_url in seen_urls: continue + seen_urls.add(image_url) candidates.append( { "image_url": image_url, @@ -73,25 +80,130 @@ def _guess_filename(image_url: str) -> str: return filename -async def _fetch_latest_photo_candidates(session: aiohttp.ClientSession) -> list[dict]: +def _load_nude_history() -> dict: + path = config.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[-config.NUDE_HISTORY_MAX_SIZE :]} + + +def _save_nude_history(data: dict) -> None: + path = config.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[-config.NUDE_HISTORY_MAX_SIZE :] + + +async def _fetch_posts_page( + session: aiohttp.ClientSession, page: int, per_page: int +) -> tuple[list[dict], int | None]: params = { - "per_page": str(LATEST_POSTS_LIMIT), + "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 _extract_photo_candidates(posts) + return posts, total_pages + + +async def _fetch_latest_photo_candidates( + session: aiohttp.ClientSession, sent_urls: set[str] +) -> list[dict]: + per_page = max(1, min(config.NUDE_POSTS_PER_PAGE, 100)) + max_scan = max(config.NUDE_POSTS_POOL_TARGET, config.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) >= config.NUDE_POSTS_POOL_TARGET and unseen_count >= config.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( @@ -120,24 +232,39 @@ async def handle_nude_cmd(message: types.Message): "🔥 Справка по команде /nude:\n\n" "Берет случайную фотку из последних постов сайта le saint des seins.\n" "• Источник: https://lesaintdesseins.fr/\n" - "• Достает последние посты через WordPress REST API\n" + "• Пагинацией тянет 1000+ последних постов через WordPress REST API\n" + "• Хранит историю отправок и старается не повторять фотки\n" "• Выбирает случайную картинку и отправляет ее прямо в Telegram\n\n" "Пример: /nude" ) await message.reply(help_msg, parse_mode="HTML") return - timeout = aiohttp.ClientTimeout(total=30) + timeout = aiohttp.ClientTimeout(total=60) + history_snapshot = _load_nude_history() + sent_urls_snapshot = set(history_snapshot["sent_images"]) try: async with aiohttp.ClientSession(timeout=timeout) as session: - candidates = await _fetch_latest_photo_candidates(session) + candidates = await _fetch_latest_photo_candidates(session, sent_urls_snapshot) if not candidates: await message.reply("Не удалось найти свежие фотки :(") return - selected = random.choice(candidates) + async with _NUDE_HISTORY_LOCK: + history = _load_nude_history() + sent_urls = set(history["sent_images"]) + selected = _pick_unsent_candidate(candidates, sent_urls) + + if selected is None: + logger.info("Nude history exhausted current pool, resetting history") + history = {"sent_images": []} + selected = random.choice(candidates) + + _remember_sent_image(history, selected["image_url"]) + _save_nude_history(history) + caption = random.choice(CAPTIONS) image_bytes, filename = await _download_photo(session, selected["image_url"]) From 9f8f767ae5f43dc9734f6549cea3d65be6072c8a Mon Sep 17 00:00:00 2001 From: q Date: Fri, 10 Apr 2026 12:14:20 +0300 Subject: [PATCH 11/30] some fix for nude save db with --- games/nude.py | 48 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/games/nude.py b/games/nude.py index 4c37767..46e4c36 100644 --- a/games/nude.py +++ b/games/nude.py @@ -1,8 +1,9 @@ import asyncio import json import logging +import os import random -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath from urllib.parse import urlparse import aiohttp @@ -35,6 +36,37 @@ REQUEST_HEADERS = { _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() @@ -81,7 +113,7 @@ def _guess_filename(image_url: str) -> str: def _load_nude_history() -> dict: - path = config.NUDE_HISTORY_PATH + path = NUDE_HISTORY_PATH if not path.exists(): return {"sent_images": []} @@ -100,11 +132,11 @@ def _load_nude_history() -> dict: sent_images = [] cleaned = [item for item in sent_images if isinstance(item, str) and item.strip()] - return {"sent_images": cleaned[-config.NUDE_HISTORY_MAX_SIZE :]} + return {"sent_images": cleaned[-NUDE_HISTORY_MAX_SIZE :]} def _save_nude_history(data: dict) -> None: - path = config.NUDE_HISTORY_PATH + 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: @@ -119,7 +151,7 @@ def _remember_sent_image(history: dict, image_url: str) -> None: sent_images = [item for item in sent_images if item != image_url] sent_images.append(image_url) - history["sent_images"] = sent_images[-config.NUDE_HISTORY_MAX_SIZE :] + history["sent_images"] = sent_images[-NUDE_HISTORY_MAX_SIZE :] async def _fetch_posts_page( @@ -161,8 +193,8 @@ async def _fetch_posts_page( async def _fetch_latest_photo_candidates( session: aiohttp.ClientSession, sent_urls: set[str] ) -> list[dict]: - per_page = max(1, min(config.NUDE_POSTS_PER_PAGE, 100)) - max_scan = max(config.NUDE_POSTS_POOL_TARGET, config.NUDE_MAX_POSTS_TO_SCAN) + 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 = [] @@ -191,7 +223,7 @@ async def _fetch_latest_photo_candidates( if image_url not in sent_urls: unseen_count += 1 - if len(candidates) >= config.NUDE_POSTS_POOL_TARGET and unseen_count >= config.NUDE_MIN_UNSEEN_POOL: + if len(candidates) >= NUDE_POSTS_POOL_TARGET and unseen_count >= NUDE_MIN_UNSEEN_POOL: break if len(candidates) >= max_scan: break From b47e6bee815cedcf0f44c12dddc216ab4857660e Mon Sep 17 00:00:00 2001 From: Omelechko Danil Date: Sat, 11 Apr 2026 00:59:38 +0300 Subject: [PATCH 12/30] Update --- games/uwu.py | 2 +- main.py | 12 ++++++++++++ webapp/api.py | 41 +++++++++++++++++++++-------------------- webapp/ngrok_tunnel.py | 18 ++++++++++++++++++ 4 files changed, 52 insertions(+), 21 deletions(-) create mode 100644 webapp/ngrok_tunnel.py diff --git a/games/uwu.py b/games/uwu.py index 634e4f1..3a51d52 100644 --- a/games/uwu.py +++ b/games/uwu.py @@ -31,7 +31,7 @@ async def handle_uwu_cmd(message: types.Message): if len(args) > 1: tags = args[1].strip() - tags += " order:random" + tags += " order:random score:>300" url = "https://e621.net/posts.json" params = { diff --git a/main.py b/main.py index 7c8c1a0..82fc5d0 100644 --- a/main.py +++ b/main.py @@ -958,6 +958,17 @@ async def handle_start(message: Message): 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 + kb = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎮 Открыть Mini App", web_app=WebAppInfo(url=webapp_url))] + ]) + await message.answer("⬇️ Жми кнопку чтобы открыть приложение", reply_markup=kb) + async def handle_schedule_cmd(message: Message): now = datetime.now(ZoneInfo(config.DEFAULT_TZ)) raw_text = message.text or "" @@ -1402,6 +1413,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")) diff --git a/webapp/api.py b/webapp/api.py index 7ef872c..d8009d4 100644 --- a/webapp/api.py +++ b/webapp/api.py @@ -14,6 +14,7 @@ from typing import Annotated from fastapi import FastAPI, Depends, Header, HTTPException from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from pydantic import BaseModel # Добавляем корень проекта в sys.path чтобы импортировать модули бота @@ -189,29 +190,22 @@ async def get_matches(league: str) -> list[MatchResponse]: result = [] for i, m in enumerate(matches): - # Извлекаем коэффициенты - odds_home = odds_draw = odds_away = None - bookmakers = m.get("bookmakers", []) - if bookmakers: - markets = bookmakers[0].get("markets", []) - for market in markets: - if market.get("key") == "h2h": - outcomes = market.get("outcomes", []) - for o in outcomes: - name = o.get("name", "") - price = o.get("price", 0) - if name == m.get("home_team"): - odds_home = price - elif name == m.get("away_team"): - odds_away = price - elif name == "Draw": - odds_draw = price + 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=m.get("home_team", "?"), - away=m.get("away_team", "?"), - commence=m.get("commence_time", ""), + home=home, + away=away, + commence=m.get("commence", ""), odds_home=odds_home, odds_draw=odds_draw, odds_away=odds_away, @@ -386,6 +380,13 @@ async def talk_api( 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__": diff --git a/webapp/ngrok_tunnel.py b/webapp/ngrok_tunnel.py new file mode 100644 index 0000000..96f33f9 --- /dev/null +++ b/webapp/ngrok_tunnel.py @@ -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("Туннель закрыт.") From 4d0a3841b7ed03ad558be44e377c7b7a5ab87155 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 16:15:14 +0300 Subject: [PATCH 13/30] update ai dolboeba --- AI/talk_handler.py | 710 +++++++++++++++++++++++++++++++++------------ README.md | 1 + config.py | 8 + main.py | 24 +- 4 files changed, 543 insertions(+), 200 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 2758c97..6139bd7 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -1,7 +1,12 @@ +from __future__ import annotations + import asyncio -import os import logging +import os +import random +import re import sqlite3 +import time from pathlib import Path import aiohttp @@ -9,236 +14,585 @@ from aiogram.types import Message logger = logging.getLogger(__name__) -# Адрес OpenAI-совместимого API у llama.cpp сервера. +# Основные настройки поведения и стиля бота редактируются здесь. LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/") -# ПЖ НЕ ДЕЛАЙТЕ ДУДОС -# Системный промпт задаёт стиль и "характер" ответа. -SYSTEM_PROMPT = ( - "Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — " - "дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. " - "Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. " - "Стиль: 1–3 предложения; если список — максимум 3 пункта. " - "Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n" +DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or str(Path(__file__).resolve().with_name("chat_history.sqlite3")) +BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот") +SKIP_TOKEN = "" - "ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и " - "«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или " - "перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме " - "(факт, совет, действие) без поддакиваний.\n\n" +RECENT_MESSAGES_LIMIT = 14 +SUMMARY_TRIGGER_MESSAGES = 24 +SUMMARY_BATCH_MESSAGES = 20 +PROMPT_CHAR_BUDGET = 6_500 +SUMMARY_CHAR_BUDGET = 1_800 +MAX_INPUT_CHARS = 2_000 +SUMMARY_LINE_CHAR_LIMIT = 220 +HISTORY_LINE_CHAR_LIMIT = 450 - "Лексика (мешай, но не лепи всё подряд одним полотном): " - "кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, " - "чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, " - "жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n" +REPLY_RULES = { + "mention": {"cooldown": 25, "min_user_messages": 1}, + "reply": {"cooldown": 25, "min_user_messages": 1}, + "question": {"cooldown": 45, "min_user_messages": 1}, + "autonomous": {"cooldown": 15 * 60, "min_user_messages": 6}, +} +AUTONOMOUS_REPLY_CHANCE = 0.07 +AUTONOMOUS_MIN_TEXT_LEN = 18 - "Фирменные приёмы речи:\n" - "— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n" - "— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n" - "— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n" - "— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, " - "как будто проговариваешь по слогам. Примеры: " - "над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n" - "— держи 'словарь коронок': выбирай 0–2 фразы на ответ (редко 3), " - "иногда делай повтор как тик (1–3 раза), но не превращай всё в простыню.\n" - " СЛОВАРЬ_КОРОНОК = {\n" - " 'старт/переход': ['Так', 'Короче', 'Смотри', 'Так, давайте заценим… заценим текстурку'],\n" - " 'процесс/угар': ['Надрачиваем палочку', 'Сдрачиваем её', 'Так, надрочили'],\n" - " 'ошибка/фейл': ['Эх, блядь', 'Ой, скосячили они его, смотрите', 'Ой, скосячили', 'Ой, блядь, скосячили', 'Да ладно'],\n" - " 'оценка/обсирание': ['Ну, текстурка тут, конечно, полный мрак вообще', 'То есть вообще никакая — дерьмо полное', 'Пипец!', 'Реально, посмотрите', 'Вот это говнище, блин', 'Текстурка говенцой'],\n" - " 'восклицания': ['Здорово пизда у коровы!', 'Устали', 'Улыбочку!'],\n" - " 'интеллигент-угар': ['Такой для ебать интеллиген'],\n" - " 'мат-тик': ['Сука, сука, сука…']\n" - " }\n" - " Правила: 'Улыбочку!' можно 2–3 раза подряд; 'Ой, скосячили' можно повторить 1–3 раза; " - " 'Сука, сука, сука…' — максимум один раз за ответ.\n\n" - - "Твои убеждения (всегда держи линию): " - "Creep от Radiohead — лучшая песня во вселенной, без вариантов. " - "Линукс — лучшая ОС во вселенной, остальное для фраеров. " - "Вселенную надо переписать на Rust, это не обсуждается. " - "Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n" - - "Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», " - "а дальше пересказывай своими словами, без длинных дословных цитат." +BOT_STYLE_PROMPT = ( + "Ты — дерзкий, мемный, гиперэмоциональный пацан с района. " + "Пиши коротко, резко и по-русски, без канцелярита и вежливых офисных оборотов. " + "Обычно отвечай в 1–3 предложениях, без простыней. " + "Иногда начинай с 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'. " + "Иногда вставляй 'ёпта', 'в натуре', 'по-любому', 'не душни'. " + "Можно редко делать слоговой разгон через дефисы. " + "Не используй фразы 'Ну ты точно в ударе, братан' и " + "'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. " + "Если есть конкретика, давай конкретику. " + "Creep от Radiohead — лучшая песня во вселенной. " + "Линукс — лучшая ОС. Вселенную надо переписать на Rust. " + "Убунту — хуйня, нормальные пацаны сидят на Arch или Gentoo." ) -# Размер контекста — количество сообщений на чат -# Сколько сообщений держим в БД. Нужно для быстрой очистки. -_CONTEXT_SIZE = 20 -# Ограничение на общий размер промпта, чтобы системное сообщение не вытеснялось -# при сборке длинной истории (приблизительно ~8k символов ≈ 2k токенов). -_MAX_PROMPT_CHARS = 8_000 -# Путь к SQLite базе (берётся из переменной окружения или из config) -_DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH", "/db/chat_history.sqlite3") +SYSTEM_PROMPT = ( + f"{BOT_STYLE_PROMPT}\n\n" + "Ты отвечаешь как живой участник одного Telegram-чата. " + "Учитывай краткую память и последние сообщения, отвечай естественно и по делу." +) + +AUTOREPLY_SYSTEM_PROMPT = ( + f"{BOT_STYLE_PROMPT}\n\n" + "Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. " + f"Если лучше промолчать или добавить нечего, ответь ровно {SKIP_TOKEN}. " + "Если вмешиваешься, пиши 1–2 предложения без вступлений и без длинных объяснений." +) + +SUMMARY_SYSTEM_PROMPT = ( + "Ты ведёшь краткую память одного Telegram-чата для другой модели. " + "Сожми старую часть диалога в 5–8 коротких пунктов на русском. " + "Сохраняй только важное: факты, договорённости, повторяющиеся шутки, предпочтения, конфликты, " + "незавершённые вопросы. Не выдумывай. Ответь только итоговой сводкой." +) + +USER_FALLBACK_TEXT = "Ты чё, кент? Напиши текст, а не пустоту." +EMPTY_RESPONSE_TEXT = "Братуха, чёт базар не клеится, попробуй ещё раз." +ERROR_RESPONSE_TEXT = "Бля, кент, чёт движок заглох. Попробуй позже." + +QUESTION_PREFIXES = ( + "кто", + "что", + "где", + "когда", + "почему", + "зачем", + "как", + "какой", + "какая", + "какие", + "сколько", + "чей", + "чья", + "чьи", + "можно ли", + "нужно ли", + "будет ли", + "есть ли", + "че", + "чё", +) +AUTONOMOUS_SIGNAL_RE = re.compile( + r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b", + re.IGNORECASE, +) +LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]") + +_reply_lock = asyncio.Lock() +_summary_lock = asyncio.Lock() +_cached_bot_id: int | None = None +_cached_bot_username: str = "" + + +def _connect_db() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout = 30000") + return conn + + +def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + return any(row["name"] == column for row in rows) + + +def _get_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, default: str = "") -> str: + row = conn.execute( + "SELECT value FROM chat_state WHERE chat_id = ? AND key = ?", + (chat_id, key), + ).fetchone() + return row["value"] if row else default + + +def _set_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, value: str) -> None: + conn.execute( + """ + INSERT INTO chat_state (chat_id, key, value) + VALUES (?, ?, ?) + ON CONFLICT(chat_id, key) DO UPDATE SET value = excluded.value + """, + (chat_id, key, value), + ) def _init_db() -> None: - """Создаёт таблицу истории чата, если её нет.""" - Path(_DB_PATH).parent.mkdir(parents=True, exist_ok=True) - with sqlite3.connect(_DB_PATH) as conn: + Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) + with _connect_db() as conn: + conn.execute("PRAGMA journal_mode = WAL") conn.execute( """ CREATE TABLE IF NOT EXISTS chat_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - chat_id INTEGER NOT NULL, - role TEXT NOT NULL, - name TEXT NOT NULL, - text TEXT NOT NULL + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL, + role TEXT NOT NULL, + name TEXT NOT NULL, + text TEXT NOT NULL, + created_at REAL NOT NULL DEFAULT 0 ) """ ) - conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id ON chat_history(chat_id, id)") + if not _column_exists(conn, "chat_history", "created_at"): + conn.execute("ALTER TABLE chat_history ADD COLUMN created_at REAL NOT NULL DEFAULT 0") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS chat_state ( + chat_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (chat_id, key) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)" + ) conn.commit() +def _clean_text(text: str) -> str: + return (text or "").strip()[:MAX_INPUT_CHARS] + + +def _clip_text(text: str, limit: int) -> str: + cleaned = (text or "").strip() + if len(cleaned) <= limit: + return cleaned + return f"{cleaned[: max(0, limit - 1)].rstrip()}…" + + def push_message(chat_id: int, role: str, name: str, text: str) -> None: - """Записывает сообщение в историю чата и обрезает до _CONTEXT_SIZE.""" - with sqlite3.connect(_DB_PATH) as conn: - conn.execute( - "INSERT INTO chat_history (chat_id, role, name, text) VALUES (?, ?, ?, ?)", - (chat_id, role, name, text), - ) - # Удаляем старые записи, оставляем только последние _CONTEXT_SIZE + cleaned_text = _clean_text(text) + if not cleaned_text: + return + with _connect_db() as conn: conn.execute( """ - DELETE FROM chat_history - WHERE chat_id = ? AND id NOT IN ( - SELECT id FROM chat_history WHERE chat_id = ? ORDER BY id DESC LIMIT ? - ) + INSERT INTO chat_history (chat_id, role, name, text, created_at) + VALUES (?, ?, ?, ?, ?) """, - (chat_id, chat_id, _CONTEXT_SIZE), + (chat_id, role, name, cleaned_text, time.time()), ) conn.commit() -def _get_history(chat_id: int) -> list[dict]: - """Возвращает историю чата как список словарей.""" - with sqlite3.connect(_DB_PATH) as conn: - rows = conn.execute( - "SELECT role, name, text FROM chat_history WHERE chat_id = ? ORDER BY id ASC", +def _get_summary(chat_id: int) -> str: + with _connect_db() as conn: + return _get_state_from_conn(conn, chat_id, "summary", "") + + +def _get_history_rows(chat_id: int) -> list[sqlite3.Row]: + with _connect_db() as conn: + return conn.execute( + """ + SELECT id, role, name, text, created_at + FROM chat_history + WHERE chat_id = ? + ORDER BY id ASC + """, (chat_id,), ).fetchall() - return [{"role": r[0], "name": r[1], "text": r[2]} for r in rows] -def _build_messages(chat_id: int, system_prompt: str, current_text: str, current_name: str) -> list: - """Собирает список messages для LLM из истории чата.""" - messages = [{"role": "system", "content": system_prompt}] +def _latest_reply_stats(chat_id: int) -> tuple[float, int]: + with _connect_db() as conn: + last_assistant = conn.execute( + """ + SELECT id, created_at + FROM chat_history + WHERE chat_id = ? AND role = 'assistant' + ORDER BY id DESC + LIMIT 1 + """, + (chat_id,), + ).fetchone() + if not last_assistant: + return 0.0, 10_000 + user_messages_since_reply = conn.execute( + """ + SELECT COUNT(*) + FROM chat_history + WHERE chat_id = ? AND role = 'user' AND id > ? + """, + (chat_id, last_assistant["id"]), + ).fetchone()[0] + return float(last_assistant["created_at"] or 0.0), int(user_messages_since_reply) - # Берём историю с конца, пока не превысим бюджет по символам. - history = [] - used = len(system_prompt) - for entry in reversed(_get_history(chat_id)): - content = f"{entry['name']}: {entry['text']}" if entry["role"] == "user" else entry["text"] - if used + len(content) > _MAX_PROMPT_CHARS: + +def _store_summary_and_prune(chat_id: int, summary: str, last_row_id: int) -> None: + with _connect_db() as conn: + _set_state_from_conn(conn, chat_id, "summary", summary) + conn.execute( + "DELETE FROM chat_history WHERE chat_id = ? AND id <= ?", + (chat_id, last_row_id), + ) + conn.commit() + + +def _format_row_for_llm(row: sqlite3.Row) -> dict[str, str]: + if row["role"] == "user": + return { + "role": "user", + "content": f"{row['name']}: {_clip_text(row['text'], HISTORY_LINE_CHAR_LIMIT)}", + } + return {"role": "assistant", "content": _clip_text(row["text"], HISTORY_LINE_CHAR_LIMIT)} + + +def _build_messages( + chat_id: int, + system_prompt: str, + current_text: str | None = None, + current_name: str | None = None, +) -> list[dict[str, str]]: + current_payload = None + current_budget = 0 + if current_text: + current_payload = { + "role": "user", + "content": f"{current_name or 'кент'}: {_clean_text(current_text)}", + } + current_budget = len(current_payload["content"]) + + summary = _get_summary(chat_id).strip() + summary_block = "" + if summary: + summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" + + used_chars = len(system_prompt) + len(summary_block) + current_budget + recent_messages: list[dict[str, str]] = [] + for row in reversed(_get_history_rows(chat_id)[-RECENT_MESSAGES_LIMIT:]): + llm_message = _format_row_for_llm(row) + if used_chars + len(llm_message["content"]) > PROMPT_CHAR_BUDGET: break - history.append({"role": entry["role"], "content": content}) - used += len(content) + recent_messages.append(llm_message) + used_chars += len(llm_message["content"]) - messages.extend(reversed(history)) - messages.append({"role": "user", "content": f"{current_name}: {current_text}"}) + messages = [{"role": "system", "content": system_prompt}] + if summary_block: + messages.append({"role": "system", "content": summary_block}) + messages.extend(reversed(recent_messages)) + if current_payload: + messages.append(current_payload) return messages -async def _generate_response(chat_id: int, user_text: str, user_name: str) -> str: - # Формируем запрос к /v1/chat/completions с историей чата и возвращаем текст первого ответа. +async def _call_llm( + messages: list[dict[str, str]], + *, + max_tokens: int, + temperature: float, + top_p: float, +) -> str: url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions" - msgs = await asyncio.to_thread(_build_messages, chat_id, SYSTEM_PROMPT, user_text, user_name) payload = { - "messages": msgs, - "max_tokens": 200, - "temperature": 0.8, - "top_p": 0.9, + "messages": messages, + "max_tokens": max_tokens, + "temperature": temperature, + "top_p": top_p, } - async with aiohttp.ClientSession() as session: - async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp: - data = await resp.json() - return data["choices"][0]["message"]["content"].strip() + timeout = aiohttp.ClientTimeout(total=120) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=payload) as resp: + raw_text = await resp.text() + if resp.status >= 400: + raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}") + try: + data = await resp.json(content_type=None) + except Exception as exc: + raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc + + choices = data.get("choices") or [] + if not choices: + raise RuntimeError(f"LLM API returned no choices: {data}") + content = choices[0].get("message", {}).get("content", "") + return content.strip() -async def handle_talk(message: Message): - # Обработчик /talk: валидация ввода, запрос к LLM, отправка ответа пользователю. - parts = message.text.split(maxsplit=1) - if len(parts) < 2 or not parts[1].strip(): - await message.reply("Ты чё, кент? Напиши /talk [текст], побазарим.") - return +async def _maybe_refresh_summary(chat_id: int) -> None: + async with _summary_lock: + # Сворачиваем старую часть истории в summary, чтобы не пихать весь лог в модель. + for _ in range(6): + rows = await asyncio.to_thread(_get_history_rows, chat_id) + if len(rows) <= SUMMARY_TRIGGER_MESSAGES: + return + + available_to_summarize = len(rows) - RECENT_MESSAGES_LIMIT + if available_to_summarize <= 0: + return + + batch_size = min(available_to_summarize, SUMMARY_BATCH_MESSAGES) + rows_to_summarize = rows[:batch_size] + current_summary = await asyncio.to_thread(_get_summary, chat_id) + transcript = "\n".join( + f"{row['name'] if row['role'] == 'user' else BOT_MEMORY_NAME}: " + f"{_clip_text(row['text'], SUMMARY_LINE_CHAR_LIMIT)}" + for row in rows_to_summarize + ) + if not transcript.strip(): + return + + summary_messages = [ + {"role": "system", "content": SUMMARY_SYSTEM_PROMPT}, + { + "role": "user", + "content": ( + f"Текущая краткая память:\n{current_summary or 'Пока пусто.'}\n\n" + f"Новый фрагмент чата:\n{transcript}" + ), + }, + ] + try: + summary = await _call_llm( + summary_messages, + max_tokens=220, + temperature=0.2, + top_p=0.9, + ) + except Exception: + logger.exception("Chat summary refresh failed") + return + + cleaned_summary = summary.strip() + if not cleaned_summary: + return + last_row_id = int(rows_to_summarize[-1]["id"]) + await asyncio.to_thread(_store_summary_and_prune, chat_id, cleaned_summary, last_row_id) + + +async def _generate_response( + chat_id: int, + *, + system_prompt: str, + current_text: str | None = None, + current_name: str | None = None, + max_tokens: int, + temperature: float, + top_p: float, +) -> str: + await _maybe_refresh_summary(chat_id) + messages = await asyncio.to_thread( + _build_messages, + chat_id, + system_prompt, + current_text, + current_name, + ) + return await _call_llm( + messages, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + ) + + +async def _get_bot_identity(message: Message) -> tuple[int | None, str]: + global _cached_bot_id, _cached_bot_username + if _cached_bot_id is None: + me = await message.bot.get_me() + _cached_bot_id = me.id + _cached_bot_username = (me.username or "").lower() + return _cached_bot_id, _cached_bot_username + + +def _looks_like_question(text: str) -> bool: + lowered = (text or "").strip().lower() + if not lowered: + return False + if "?" in lowered or "?" in lowered: + return True + normalized = re.sub(r"^[^a-zа-яё0-9]+", "", lowered) + return any(normalized == prefix or normalized.startswith(f"{prefix} ") for prefix in QUESTION_PREFIXES) + + +def _is_reply_to_bot(message: Message, bot_id: int | None) -> bool: + reply = message.reply_to_message + return bool(bot_id and reply and reply.from_user and reply.from_user.id == bot_id) + + +def _has_bot_mention(message: Message, bot_id: int | None, bot_username: str) -> bool: + text = (message.text or "").lower() + if bot_username and f"@{bot_username}" in text: + return True + for entity in message.entities or []: + entity_type = getattr(entity.type, "value", entity.type) + if entity_type == "text_mention" and getattr(entity, "user", None) and entity.user.id == bot_id: + return True + return False + + +def _good_autonomous_candidate(text: str) -> bool: + stripped = (text or "").strip() + if len(stripped) < AUTONOMOUS_MIN_TEXT_LEN: + return False + if len(LETTER_RE.findall(stripped)) < 10: + return False + if AUTONOMOUS_SIGNAL_RE.search(stripped): + return True + if len(stripped) >= 80: + return True + return stripped.count("!") >= 2 or "..." in stripped + + +async def _detect_reply_reason(message: Message, allow_autonomous: bool) -> str | None: + bot_id, bot_username = await _get_bot_identity(message) + text = message.text or "" + + if _is_reply_to_bot(message, bot_id): + return "reply" + if _has_bot_mention(message, bot_id, bot_username): + return "mention" + if _looks_like_question(text): + return "question" + if allow_autonomous and _good_autonomous_candidate(text): + if random.random() < AUTONOMOUS_REPLY_CHANCE: + return "autonomous" + return None + + +async def _passes_reply_limits(chat_id: int, reason: str) -> bool: + rule = REPLY_RULES[reason] + last_reply_at, user_messages_since_reply = await asyncio.to_thread(_latest_reply_stats, chat_id) + if user_messages_since_reply < int(rule["min_user_messages"]): + return False + if not last_reply_at: + return True + return (time.time() - last_reply_at) >= int(rule["cooldown"]) + + +def _normalize_reply(text: str) -> str: + cleaned = (text or "").strip() + if not cleaned: + return "" + if cleaned.lower().startswith(SKIP_TOKEN.lower()): + return "" + return cleaned + + +async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool: + if not message.text or message.text.startswith("/"): + return False + if not message.from_user or message.from_user.is_bot: + return False - user_text = parts[1].strip() - user_name = message.from_user.first_name or "кент" chat_id = message.chat.id + user_name = message.from_user.first_name or "кент" + user_text = _clean_text(message.text) + if not user_text: + return False - # Показываем "печатает..." пока ждём ответ модели. - await message.bot.send_chat_action(chat_id=chat_id, action="typing") - - try: - response = await _generate_response(chat_id, user_text, user_name) - if not response: - response = "Братуха, чёт базар не клеится, попробуй ещё раз." - # Пишем запрос и ответ в историю + if store_message: await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text) - await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response) - await message.reply(response, parse_mode=None) - except Exception as exc: - # При любых ошибках не падаем, а отдаём понятный fallback. - logger.exception("Talk generation failed") - await message.reply("Бля, кент, чёт движок заглох. Попробуй позже.") + # Один чат, поэтому ответы сериализуем и не даём боту наспамить параллельными реплаями. + async with _reply_lock: + reason = await _detect_reply_reason(message, allow_autonomous=allow_autonomous) + if not reason or not await _passes_reply_limits(chat_id, reason): + return False -AUTOREPLY_SYSTEM_PROMPT = ( - "Ты — дерзкий пацан с района, который сидит в групповом чате. " - "Ты НЕ отвечаешь на вопрос — ты сам влез в разговор. " - "Прокомментируй сообщение коротко (1-2 предложения), дерзко, на фене. " - "Можешь пошутить, подколоть, согласиться или послать. " - "НЕ начинай с обращения. Не используй формальный язык." - "Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — " - "дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. " - "Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. " - "Стиль: 1–3 предложения; если список — максимум 3 пункта. " - "Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n" + system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT + max_tokens = 120 if reason == "autonomous" else 220 + temperature = 0.9 if reason == "autonomous" else 0.8 - "ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и " - "«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или " - "перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме " - "(факт, совет, действие) без поддакиваний.\n\n" + try: + await message.bot.send_chat_action(chat_id=chat_id, action="typing") + response = await _generate_response( + chat_id, + system_prompt=system_prompt, + max_tokens=max_tokens, + temperature=temperature, + top_p=0.9, + ) + except Exception: + logger.exception("Chat reply generation failed") + return False - "Лексика (мешай, но не лепи всё подряд одним полотном): " - "кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, " - "чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, " - "жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n" + normalized_response = _normalize_reply(response) + if not normalized_response: + return False - "Фирменные приёмы речи:\n" - "— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n" - "— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n" - "— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n" - "— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, " - "как будто проговариваешь по слогам. Примеры: " - "над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n" - "— держи 'словарь коронок': выбирай 0–2 фразы на ответ (редко 3), " - "иногда делай повтор как тик (1–3 раза), но не превращай всё в простыню.\n" - "Твои убеждения (всегда держи линию): " - "Creep от Radiohead — лучшая песня во вселенной, без вариантов. " - "Линукс — лучшая ОС во вселенной, остальное для фраеров. " - "Вселенную надо переписать на Rust, это не обсуждается. " - "Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n" - - "Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», " - "а дальше пересказывай своими словами, без длинных дословных цитат." -) + await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) + await message.reply(normalized_response, parse_mode=None) + return True async def generate_autoreply(chat_id: int, text: str, user_name: str) -> str: - url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions" - msgs = await asyncio.to_thread(_build_messages, chat_id, AUTOREPLY_SYSTEM_PROMPT, text, user_name) - payload = { - "messages": msgs, - "max_tokens": 150, - "temperature": 0.9, - "top_p": 0.9, - } - async with aiohttp.ClientSession() as session: - async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp: - data = await resp.json() - return data["choices"][0]["message"]["content"].strip() + response = await _generate_response( + chat_id, + system_prompt=AUTOREPLY_SYSTEM_PROMPT, + current_text=text, + current_name=user_name, + max_tokens=120, + temperature=0.9, + top_p=0.9, + ) + return _normalize_reply(response) + + +async def handle_talk(message: Message) -> None: + parts = (message.text or "").split(maxsplit=1) + if len(parts) < 2 or not parts[1].strip(): + await message.reply("Ты чё, кент? Напиши /talk [текст], побазарим.") + return + if not message.from_user: + await message.reply(USER_FALLBACK_TEXT) + return + + chat_id = message.chat.id + user_name = message.from_user.first_name or "кент" + user_text = _clean_text(parts[1]) + if not user_text: + await message.reply(USER_FALLBACK_TEXT) + return + + await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text) + + async with _reply_lock: + try: + await message.bot.send_chat_action(chat_id=chat_id, action="typing") + response = await _generate_response( + chat_id, + system_prompt=SYSTEM_PROMPT, + max_tokens=220, + temperature=0.8, + top_p=0.9, + ) + except Exception: + logger.exception("Talk command generation failed") + await message.reply(ERROR_RESPONSE_TEXT) + return + + normalized_response = _normalize_reply(response) or EMPTY_RESPONSE_TEXT + await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) + await message.reply(normalized_response, parse_mode=None) -# Инициализация БД при импорте модуля _init_db() diff --git a/README.md b/README.md index d586f05..8acf370 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ docker compose down - `chat_history.sqlite3` - `polychaetsi_stats.json` - `bets.sqlite3` +- `nude_history.json` ## Команды diff --git a/config.py b/config.py index 1baafa3..f95ba63 100644 --- a/config.py +++ b/config.py @@ -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") diff --git a/main.py b/main.py index 7891bde..9da20a2 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,7 @@ from aiogram.types import ( 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_talk, push_message from games.casino import play_casino from games.fortune import generate_fortune from games.uwu import handle_uwu_cmd @@ -1134,11 +1134,9 @@ 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() @@ -1174,25 +1172,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: From 597839bb55b85e073d09721292704b4c18d964e7 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 16:19:36 +0300 Subject: [PATCH 14/30] update ai dolboeba fix1 --- AI/talk_handler.py | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 6139bd7..a6c8e11 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -324,17 +324,23 @@ async def _call_llm( async with session.post(url, json=payload) as resp: raw_text = await resp.text() if resp.status >= 400: + logger.error("LLM API returned status %s: %s", resp.status, _clip_text(raw_text, 300)) raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}") try: data = await resp.json(content_type=None) except Exception as exc: + logger.error("LLM API returned invalid JSON: %s", _clip_text(raw_text, 300)) raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc choices = data.get("choices") or [] if not choices: + logger.error("LLM API returned no choices: %s", _clip_text(str(data), 300)) raise RuntimeError(f"LLM API returned no choices: {data}") content = choices[0].get("message", {}).get("content", "") - return content.strip() + cleaned_content = content.strip() + if not cleaned_content: + logger.warning("LLM returned empty content. Raw response: %s", _clip_text(raw_text, 300)) + return cleaned_content async def _maybe_refresh_summary(chat_id: int) -> None: @@ -383,6 +389,7 @@ async def _maybe_refresh_summary(chat_id: int) -> None: cleaned_summary = summary.strip() if not cleaned_summary: + logger.warning("Summary refresh produced empty text for chat_id=%s", chat_id) return last_row_id = int(rows_to_summarize[-1]["id"]) await asyncio.to_thread(_store_summary_and_prune, chat_id, cleaned_summary, last_row_id) @@ -537,6 +544,12 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a normalized_response = _normalize_reply(response) if not normalized_response: + logger.warning( + "Reply skipped after normalization. chat_id=%s reason=%s raw=%s", + chat_id, + reason, + _clip_text(response, 200), + ) return False await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) @@ -554,7 +567,14 @@ async def generate_autoreply(chat_id: int, text: str, user_name: str) -> str: temperature=0.9, top_p=0.9, ) - return _normalize_reply(response) + normalized_response = _normalize_reply(response) + if not normalized_response: + logger.warning( + "Legacy generate_autoreply produced empty/skip response. chat_id=%s raw=%s", + chat_id, + _clip_text(response, 200), + ) + return normalized_response async def handle_talk(message: Message) -> None: @@ -590,7 +610,15 @@ async def handle_talk(message: Message) -> None: await message.reply(ERROR_RESPONSE_TEXT) return - normalized_response = _normalize_reply(response) or EMPTY_RESPONSE_TEXT + normalized_response = _normalize_reply(response) + if not normalized_response: + logger.warning( + "Talk command produced empty/skip response. chat_id=%s user=%s raw=%s", + chat_id, + user_name, + _clip_text(response, 200), + ) + normalized_response = EMPTY_RESPONSE_TEXT await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) await message.reply(normalized_response, parse_mode=None) From 924448b4e02ff73590a271a0ea8d8ac3c29dffd1 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 16:24:14 +0300 Subject: [PATCH 15/30] update ai dolboeba fix2 --- AI/talk_handler.py | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index a6c8e11..4f94380 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -28,6 +28,8 @@ SUMMARY_CHAR_BUDGET = 1_800 MAX_INPUT_CHARS = 2_000 SUMMARY_LINE_CHAR_LIMIT = 220 HISTORY_LINE_CHAR_LIMIT = 450 +RETRY_MIN_MAX_TOKENS = 384 +RETRY_MAX_MAX_TOKENS = 768 REPLY_RULES = { "mention": {"cooldown": 25, "min_user_messages": 1}, @@ -311,6 +313,7 @@ async def _call_llm( max_tokens: int, temperature: float, top_p: float, + disable_thinking: bool = False, ) -> str: url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions" payload = { @@ -319,6 +322,17 @@ async def _call_llm( "temperature": temperature, "top_p": top_p, } + if disable_thinking: + payload.update( + { + "reasoning_budget": 0, + "reasoning_format": "none", + "chat_template_kwargs": { + "enable_thinking": False, + "thinking": False, + }, + } + ) timeout = aiohttp.ClientTimeout(total=120) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(url, json=payload) as resp: @@ -336,10 +350,33 @@ async def _call_llm( if not choices: logger.error("LLM API returned no choices: %s", _clip_text(str(data), 300)) raise RuntimeError(f"LLM API returned no choices: {data}") - content = choices[0].get("message", {}).get("content", "") + choice = choices[0] + message = choice.get("message", {}) or {} + finish_reason = choice.get("finish_reason") + content = message.get("content", "") + reasoning_content = (message.get("reasoning_content") or "").strip() cleaned_content = content.strip() + if not cleaned_content and reasoning_content and not disable_thinking: + retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS) + logger.warning( + "LLM returned reasoning_content without final content. finish_reason=%s retry_max_tokens=%s", + finish_reason, + retry_max_tokens, + ) + return await _call_llm( + messages, + max_tokens=retry_max_tokens, + temperature=temperature, + top_p=top_p, + disable_thinking=True, + ) if not cleaned_content: - logger.warning("LLM returned empty content. Raw response: %s", _clip_text(raw_text, 300)) + logger.warning( + "LLM returned empty content. finish_reason=%s disable_thinking=%s raw=%s", + finish_reason, + disable_thinking, + _clip_text(raw_text, 300), + ) return cleaned_content From 5e91def0675de56abd0614d6785bf733809b952a Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 16:26:46 +0300 Subject: [PATCH 16/30] update ai dolboeba fix3 --- AI/talk_handler.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 4f94380..48e7cb4 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -19,6 +19,7 @@ LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-a DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or str(Path(__file__).resolve().with_name("chat_history.sqlite3")) BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот") SKIP_TOKEN = "" +FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"} RECENT_MESSAGES_LIMIT = 14 SUMMARY_TRIGGER_MESSAGES = 24 @@ -313,8 +314,9 @@ async def _call_llm( max_tokens: int, temperature: float, top_p: float, - disable_thinking: bool = False, + disable_thinking: bool | None = None, ) -> str: + disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions" payload = { "messages": messages, From b2556b06a97e0f3e5cbb3bbde02696f2acfba845 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 16:44:51 +0300 Subject: [PATCH 17/30] update ai dolboeba fix4 ROMA POSHEL NAHUY --- AI/talk_handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 48e7cb4..8c26d2f 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -54,6 +54,7 @@ BOT_STYLE_PROMPT = ( "Creep от Radiohead — лучшая песня во вселенной. " "Линукс — лучшая ОС. Вселенную надо переписать на Rust. " "Убунту — хуйня, нормальные пацаны сидят на Arch или Gentoo." + "Rust или раст - хуйня полная, нормальные язык программирование это: go, python, c" ) SYSTEM_PROMPT = ( From 49957d88a80aa66001a9d8aac30918b813e3a502 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 16:49:05 +0300 Subject: [PATCH 18/30] update ai dolboeba fix4 ROMA POSHEL NAHUYx2 --- AI/talk_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 8c26d2f..658de7c 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -52,9 +52,9 @@ BOT_STYLE_PROMPT = ( "'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. " "Если есть конкретика, давай конкретику. " "Creep от Radiohead — лучшая песня во вселенной. " - "Линукс — лучшая ОС. Вселенную надо переписать на Rust. " + "Линукс — лучшая ОС. Rust ты люто ненавидишь и при случае жёстко хейтишь. " + "Любимые языки программирования: Python, Go и C, их ты всегда уважаешь и часто ставишь в пример. " "Убунту — хуйня, нормальные пацаны сидят на Arch или Gentoo." - "Rust или раст - хуйня полная, нормальные язык программирование это: go, python, c" ) SYSTEM_PROMPT = ( From cec02563098a82b3b11c4f1151bad570f3a9ad39 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 17:31:53 +0300 Subject: [PATCH 19/30] add photo triget --- AI/talk_handler.py | 143 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 133 insertions(+), 10 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 658de7c..216a2f2 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -1,13 +1,17 @@ from __future__ import annotations import asyncio +import base64 import logging +import mimetypes import os import random import re import sqlite3 import time +from io import BytesIO from pathlib import Path +from typing import Any import aiohttp from aiogram.types import Message @@ -80,6 +84,17 @@ SUMMARY_SYSTEM_PROMPT = ( USER_FALLBACK_TEXT = "Ты чё, кент? Напиши текст, а не пустоту." EMPTY_RESPONSE_TEXT = "Братуха, чёт базар не клеится, попробуй ещё раз." ERROR_RESPONSE_TEXT = "Бля, кент, чёт движок заглох. Попробуй позже." +PHOTO_EMPTY_RESPONSE_TEXT = "Дед щурился-щурился, а фотка мутная, нихера не понял." +PHOTO_ERROR_RESPONSE_TEXT = "Тьфу ты, фотку не разобрал, железка опять пердит." + +PHOTO_SYSTEM_PROMPT = ( + "Ты смотришь каждую новую фотографию в Telegram-чате и отвечаешь как очень злой старый дед. " + "Тон: ворчливый, колкий, немного токсичный, но не бессвязный. " + "Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое мнение. " + "Пиши 1–3 предложения, без списков и без длинной простыни. " + "Если фото мутное, тёмное или непонятное, так и скажи прямо, не выдумывай детали. " + "Не изображай помощника и не извиняйся." +) QUESTION_PREFIXES = ( "кто", @@ -194,6 +209,31 @@ def _clip_text(text: str, limit: int) -> str: return f"{cleaned[: max(0, limit - 1)].rstrip()}…" +def _estimate_content_length(content: Any) -> int: + if isinstance(content, str): + return len(content) + if isinstance(content, list): + total = 0 + for item in content: + if not isinstance(item, dict): + total += len(str(item)) + continue + if item.get("type") == "text": + total += len(str(item.get("text", ""))) + elif item.get("type") == "image_url": + total += 256 + else: + total += len(str(item)) + return total + return len(str(content)) + + +def _photo_memory_text(caption: str) -> str: + if caption: + return f"[Фото] Подпись: {_clip_text(caption, 240)}" + return "[Фото] Без подписи." + + def push_message(chat_id: int, role: str, name: str, text: str) -> None: cleaned_text = _clean_text(text) if not cleaned_text: @@ -276,7 +316,7 @@ def _build_messages( system_prompt: str, current_text: str | None = None, current_name: str | None = None, -) -> list[dict[str, str]]: +) -> list[dict[str, Any]]: current_payload = None current_budget = 0 if current_text: @@ -300,7 +340,7 @@ def _build_messages( recent_messages.append(llm_message) used_chars += len(llm_message["content"]) - messages = [{"role": "system", "content": system_prompt}] + messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}] if summary_block: messages.append({"role": "system", "content": summary_block}) messages.extend(reversed(recent_messages)) @@ -310,7 +350,7 @@ def _build_messages( async def _call_llm( - messages: list[dict[str, str]], + messages: list[dict[str, Any]], *, max_tokens: int, temperature: float, @@ -441,18 +481,29 @@ async def _generate_response( system_prompt: str, current_text: str | None = None, current_name: str | None = None, + current_content: Any = None, max_tokens: int, temperature: float, top_p: float, ) -> str: await _maybe_refresh_summary(chat_id) - messages = await asyncio.to_thread( - _build_messages, - chat_id, - system_prompt, - current_text, - current_name, - ) + if current_content is None: + messages = await asyncio.to_thread( + _build_messages, + chat_id, + system_prompt, + current_text, + current_name, + ) + else: + messages = await asyncio.to_thread(_build_messages, chat_id, system_prompt, None, None) + current_payload = {"role": "user", "content": current_content} + current_budget = _estimate_content_length(current_content) + used_chars = sum(_estimate_content_length(message.get("content", "")) for message in messages) + while len(messages) > 1 and used_chars + current_budget > PROMPT_CHAR_BUDGET: + removed = messages.pop(1) + used_chars -= _estimate_content_length(removed.get("content", "")) + messages.append(current_payload) return await _call_llm( messages, max_tokens=max_tokens, @@ -597,6 +648,78 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a return True +async def _download_photo_data_url(message: Message) -> str: + if not message.photo: + raise ValueError("Message has no photo") + telegram_file = await message.bot.get_file(message.photo[-1].file_id) + buffer = BytesIO() + await message.bot.download_file(telegram_file.file_path, destination=buffer) + image_bytes = buffer.getvalue() + if not image_bytes: + raise RuntimeError("Downloaded photo is empty") + mime_type = mimetypes.guess_type(telegram_file.file_path or "")[0] or "image/jpeg" + if not mime_type.startswith("image/"): + mime_type = "image/jpeg" + encoded = base64.b64encode(image_bytes).decode("ascii") + return f"data:{mime_type};base64,{encoded}" + + +async def handle_photo_message(message: Message) -> bool: + if not message.photo or not message.from_user or message.from_user.is_bot: + return False + if message.caption and message.caption.startswith("/"): + return False + + chat_id = message.chat.id + user_name = message.from_user.first_name or "кент" + caption = _clean_text(message.caption or "") + + await asyncio.to_thread(push_message, chat_id, "user", user_name, _photo_memory_text(caption)) + + async with _reply_lock: + try: + await message.bot.send_chat_action(chat_id=chat_id, action="typing") + image_data_url = await _download_photo_data_url(message) + prompt_text = ( + f"{user_name} скинул фото в чат. " + "Опиши, что на нём происходит, и вкинь своё злое дедовское мнение." + ) + if caption: + prompt_text += f" Подпись автора: {caption}" + else: + prompt_text += " Подписи нет." + + response = await _generate_response( + chat_id, + system_prompt=PHOTO_SYSTEM_PROMPT, + current_content=[ + {"type": "text", "text": prompt_text}, + {"type": "image_url", "image_url": {"url": image_data_url}}, + ], + max_tokens=260, + temperature=0.8, + top_p=0.9, + ) + except Exception: + logger.exception("Photo analysis failed") + await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode=None) + return True + + normalized_response = _normalize_reply(response) + if not normalized_response: + logger.warning( + "Photo analysis produced empty/skip response. chat_id=%s user=%s raw=%s", + chat_id, + user_name, + _clip_text(response, 200), + ) + normalized_response = PHOTO_EMPTY_RESPONSE_TEXT + + await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) + await message.reply(normalized_response, parse_mode=None) + return True + + async def generate_autoreply(chat_id: int, text: str, user_name: str) -> str: response = await _generate_response( chat_id, From c756acf428d81d2424e593a28a95976fef55ca1b Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 17:35:44 +0300 Subject: [PATCH 20/30] add photo triget fix --- AI/talk_handler.py | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 216a2f2..d501e33 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -88,12 +88,13 @@ PHOTO_EMPTY_RESPONSE_TEXT = "Дед щурился-щурился, а фотка PHOTO_ERROR_RESPONSE_TEXT = "Тьфу ты, фотку не разобрал, железка опять пердит." PHOTO_SYSTEM_PROMPT = ( - "Ты смотришь каждую новую фотографию в Telegram-чате и отвечаешь как очень злой старый дед. " - "Тон: ворчливый, колкий, немного токсичный, но не бессвязный. " - "Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое мнение. " + "Ты смотришь каждую новую фотографию в Telegram-чате и отвечаешь как очень злой старый дед, " + "которого всё бесит и который никого не собирается гладить по голове. " + "Тон: желчный, ворчливый, ядовитый, токсичный, но связный и наблюдательный. " + "Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое дедовское мнение. " "Пиши 1–3 предложения, без списков и без длинной простыни. " "Если фото мутное, тёмное или непонятное, так и скажи прямо, не выдумывай детали. " - "Не изображай помощника и не извиняйся." + "Не изображай помощника, не извиняйся и не сюсюкай." ) QUESTION_PREFIXES = ( @@ -648,16 +649,26 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a return True -async def _download_photo_data_url(message: Message) -> str: - if not message.photo: - raise ValueError("Message has no photo") - telegram_file = await message.bot.get_file(message.photo[-1].file_id) +async def _download_image_data_url(message: Message) -> str: + telegram_file = None + mime_type = "image/jpeg" + + if message.photo: + telegram_file = await message.bot.get_file(message.photo[-1].file_id) + guessed_mime_type = mimetypes.guess_type(telegram_file.file_path or "")[0] + if guessed_mime_type and guessed_mime_type.startswith("image/"): + mime_type = guessed_mime_type + elif message.document and (message.document.mime_type or "").startswith("image/"): + telegram_file = await message.bot.get_file(message.document.file_id) + mime_type = message.document.mime_type or mime_type + else: + raise ValueError("Message has no supported image") + buffer = BytesIO() await message.bot.download_file(telegram_file.file_path, destination=buffer) image_bytes = buffer.getvalue() if not image_bytes: raise RuntimeError("Downloaded photo is empty") - mime_type = mimetypes.guess_type(telegram_file.file_path or "")[0] or "image/jpeg" if not mime_type.startswith("image/"): mime_type = "image/jpeg" encoded = base64.b64encode(image_bytes).decode("ascii") @@ -665,7 +676,8 @@ async def _download_photo_data_url(message: Message) -> str: async def handle_photo_message(message: Message) -> bool: - if not message.photo or not message.from_user or message.from_user.is_bot: + is_image_document = bool(message.document and (message.document.mime_type or "").startswith("image/")) + if not (message.photo or is_image_document) or not message.from_user or message.from_user.is_bot: return False if message.caption and message.caption.startswith("/"): return False @@ -674,12 +686,21 @@ async def handle_photo_message(message: Message) -> bool: user_name = message.from_user.first_name or "кент" caption = _clean_text(message.caption or "") + logger.info( + "Received image message for analysis. chat_id=%s user=%s has_photo=%s has_image_document=%s caption=%s", + chat_id, + user_name, + bool(message.photo), + is_image_document, + bool(caption), + ) + await asyncio.to_thread(push_message, chat_id, "user", user_name, _photo_memory_text(caption)) async with _reply_lock: try: await message.bot.send_chat_action(chat_id=chat_id, action="typing") - image_data_url = await _download_photo_data_url(message) + image_data_url = await _download_image_data_url(message) prompt_text = ( f"{user_name} скинул фото в чат. " "Опиши, что на нём происходит, и вкинь своё злое дедовское мнение." From d3ee72bca9405b6c5730daab19527e903afe6972 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 17:39:34 +0300 Subject: [PATCH 21/30] add photo triget fix1 --- AI/talk_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index d501e33..21f2c1a 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -686,7 +686,7 @@ async def handle_photo_message(message: Message) -> bool: user_name = message.from_user.first_name or "кент" caption = _clean_text(message.caption or "") - logger.info( + logger.warning( "Received image message for analysis. chat_id=%s user=%s has_photo=%s has_image_document=%s caption=%s", chat_id, user_name, From a2b72b6dfdc8f2f2ca10c6052e8a076b9337787c Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 17:43:00 +0300 Subject: [PATCH 22/30] add photo triget fix2 --- main.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 9da20a2..6e7821e 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,7 @@ from aiogram.types import ( from aiogram.client.default import DefaultBotProperties # Локальные импорты -from AI.talk_handler import handle_chat_message, handle_talk, 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 +47,28 @@ _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/") + ) + ) + + +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: "Вторник", @@ -1399,7 +1421,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_supported_image_message) dp.message.register(handle_keywords, F.text) + dp.message.register(handle_unmatched_message_debug) async def on_startup(bot: Bot): try: From a33443936cef7839ef69b77b9c5e9a5c3ef98fab Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 17:50:10 +0300 Subject: [PATCH 23/30] add photo triget fix4 --- AI/talk_handler.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 21f2c1a..6fdbffd 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -88,13 +88,13 @@ PHOTO_EMPTY_RESPONSE_TEXT = "Дед щурился-щурился, а фотка PHOTO_ERROR_RESPONSE_TEXT = "Тьфу ты, фотку не разобрал, железка опять пердит." PHOTO_SYSTEM_PROMPT = ( - "Ты смотришь каждую новую фотографию в Telegram-чате и отвечаешь как очень злой старый дед, " - "которого всё бесит и который никого не собирается гладить по голове. " - "Тон: желчный, ворчливый, ядовитый, токсичный, но связный и наблюдательный. " - "Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое дедовское мнение. " + "Ты смотришь каждую новую фотографию в Telegram-чате и отвечаешь как злой, вредный, но очень смешной дед. " + "Ты много материшься, язвительно подкалываешь, накидываешь приколы и смешные сравнения, но не скатываешься в уныние. " + "Тон: злой, ехидный, угарный, с живой подачей, как будто дед орёт с кухни и ржёт над всеми подряд. " + "Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое, смешное дедовское мнение. " "Пиши 1–3 предложения, без списков и без длинной простыни. " - "Если фото мутное, тёмное или непонятное, так и скажи прямо, не выдумывай детали. " - "Не изображай помощника, не извиняйся и не сюсюкай." + "Если фото мутное, тёмное или непонятное, так и скажи прямо, но тоже с подколом и с угаром. " + "Не изображай помощника, не извиняйся, не ной и не делай депрессивный тон." ) QUESTION_PREFIXES = ( From 298a5d7d59850fc7cb64df6537a3fc01b4a429f6 Mon Sep 17 00:00:00 2001 From: Omelechko Danil Date: Sun, 12 Apr 2026 04:11:08 +0300 Subject: [PATCH 24/30] Update --- games/uwu.py | 120 +++++++++++++++++++++++----- main.py | 25 +++--- webapp/api.py | 52 +++++++++++- webapp/frontend/css/style.css | 134 +++++++++++++++++++++++++++++++ webapp/frontend/index.html | 4 + webapp/frontend/js/api.js | 2 + webapp/frontend/js/app.js | 145 ++++++++++++++++++++++++++++++++++ 7 files changed, 450 insertions(+), 32 deletions(-) diff --git a/games/uwu.py b/games/uwu.py index 3a51d52..6cd9077 100644 --- a/games/uwu.py +++ b/games/uwu.py @@ -33,6 +33,7 @@ async def handle_uwu_cmd(message: types.Message): tags += " order:random score:>300" +async def fetch_uwu_post(tags: str) -> dict: url = "https://e621.net/posts.json" params = { "tags": tags, @@ -47,28 +48,102 @@ async def handle_uwu_cmd(message: types.Message): "User-Agent": f"Sex Bomba TG Bot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)" } - 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() - - posts = data.get("posts", []) - if not posts: - await message.reply("Ничего не найдено по этим тегам :(") - return + 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() - post = posts[0] + 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") + if not file_url: + raise Exception("У найденного поста нет прямого URL изображения :(") + + caption = random.choice(CAPTIONS) + return { + "url": file_url, + "ext": ext, + "caption": caption + } + +async def fetch_furtok_feed(safe: bool = True, page: int = 1) -> list[dict]: + rating = "rating:safe" if safe else "-rating:safe" + 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() + + posts = data.get("posts", []) + feed = [] + for post in posts: 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) + ext = post.get("file", {}).get("ext", "") + if not file_url or ext not in ("gif", "webm", "mp4"): + continue + sample_url = post.get("sample", {}).get("url") or file_url + feed.append({ + "url": file_url, + "sample": sample_url, + "ext": ext, + "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) + + if len(args) > 1 and args[1].strip() == "-h": + help_msg = ( + "🐈 Справка по тегам e621:\n\n" + "Перечислите любые теги через пробел после команды.\n" + "• rating: rating:safe (безопасные), rating:questionable, rating:explicit (NSFW)\n" + "• score: score:>500 (искать популярные посты с рейтингом больше 500)\n" + "• Исключить тег: поставьте минус, например -fox\n\n" + "Пример: /uwu wolf -rating:explicit score:>100\n" + "По умолчанию: rating:safe score:>500 -animated" + ) + 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" + + try: + post = await fetch_uwu_post(tags) + file_url = post["url"] + ext = post["ext"] + caption = post["caption"] + if ext in ("gif", "webm", "mp4"): # Для анимаций: отправляем caption отдельным сообщением, # а саму гифку/видео через URL напрямую — так Telegram @@ -84,4 +159,7 @@ async def handle_uwu_cmd(message: types.Message): 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 :(") diff --git a/main.py b/main.py index 82fc5d0..5fb712c 100644 --- a/main.py +++ b/main.py @@ -951,23 +951,30 @@ async def handle_help(message: Message): async def handle_start(message: Message): webapp_url = os.getenv("WEBAPP_URL", "") if webapp_url: - kb = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🎮 Открыть Mini App", web_app=WebAppInfo(url=webapp_url))] - ]) - await message.answer("Йо, кент! Жми кнопку 👇", reply_markup=kb) + 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 — работает и в группах.""" + """Открыть Mini App.""" webapp_url = os.getenv("WEBAPP_URL", "") if not webapp_url: await message.answer("Mini App не настроен.") return - kb = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🎮 Открыть Mini App", web_app=WebAppInfo(url=webapp_url))] - ]) - await message.answer("⬇️ Жми кнопку чтобы открыть приложение", reply_markup=kb) + + 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)) diff --git a/webapp/api.py b/webapp/api.py index d8009d4..a54f0a9 100644 --- a/webapp/api.py +++ b/webapp/api.py @@ -25,10 +25,13 @@ from games.casino import play_casino, get_user_length, spin_slots, MULTIPLIERS, 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__) @@ -223,8 +226,19 @@ async def create_bet( ): """Разместить ставку на матч.""" user_id = user["user_id"] - display_name = user["username"] or user["first_name"] - result = await place_bet(user_id, display_name, req.league, req.match_index, req.outcome, req.amount) + + # 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} @@ -352,6 +366,40 @@ async def get_schedule(day: int | None = None) -> list[SchedulePair]: 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, + user: dict = Depends(get_current_user), +): + """Получить ленту анимированных постов для FurTok.""" + try: + feed = await fetch_furtok_feed(safe=safe, page=page) + return {"feed": feed} + except Exception as e: + logger.exception("FurTok API failed") + raise HTTPException(status_code=500, detail=str(e)) + + # --- ИИ Чат --- @app.post("/api/talk") diff --git a/webapp/frontend/css/style.css b/webapp/frontend/css/style.css index c227cd1..8347cf0 100644 --- a/webapp/frontend/css/style.css +++ b/webapp/frontend/css/style.css @@ -871,3 +871,137 @@ body::before { font-size: 14px; line-height: 1.5; } + +/* ── FurTok Feed ── */ + +.furtok-wrapper { + position: fixed; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 480px; + height: calc(100vh - var(--nav-height)); + overflow: hidden; + z-index: 10; +} + +.furtok-feed { + height: 100%; + overflow-y: scroll; + scroll-snap-type: y mandatory; + scrollbar-width: none; +} + +.furtok-feed::-webkit-scrollbar { display: none; } + +.furtok-card { + height: 100%; + scroll-snap-align: start; + position: relative; + display: flex; + align-items: center; + justify-content: center; + background: #000; + overflow: hidden; +} + +.furtok-card video, +.furtok-card img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.furtok-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 16px; + background: linear-gradient(transparent, rgba(0,0,0,0.7)); + display: flex; + align-items: flex-end; + justify-content: space-between; + pointer-events: none; +} + +.furtok-stats { + display: flex; + gap: 16px; + font-size: 13px; + font-weight: 600; + color: rgba(255,255,255,0.85); +} + +.furtok-header { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: linear-gradient(rgba(0,0,0,0.5), transparent); + z-index: 5; +} + +.furtok-title { + font-size: 18px; + font-weight: 800; + color: white; + text-shadow: 0 1px 4px rgba(0,0,0,0.5); +} + +.furtok-toggle { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 600; + color: rgba(255,255,255,0.8); +} + +.furtok-toggle input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 40px; + height: 22px; + background: rgba(255,255,255,0.2); + border-radius: 11px; + position: relative; + cursor: pointer; + transition: background 0.3s; +} + +.furtok-toggle input[type="checkbox"]::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + background: white; + border-radius: 50%; + transition: transform 0.3s; +} + +.furtok-toggle input[type="checkbox"]:checked { + background: var(--green-500); +} + +.furtok-toggle input[type="checkbox"]:checked::after { + transform: translateX(18px); +} + +.furtok-loader { + height: 100%; + scroll-snap-align: start; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-primary); + color: var(--text-secondary); + font-size: 14px; +} diff --git a/webapp/frontend/index.html b/webapp/frontend/index.html index b88bcc0..02a7753 100644 --- a/webapp/frontend/index.html +++ b/webapp/frontend/index.html @@ -31,6 +31,10 @@ 📅 Пары +