feature/FenyaBot blur questionable (q) rating in /uwu command #36

Merged
q merged 4 commits from Dan4ick/bot_tg:feature/FenyaBot into main 2026-05-20 10:00:13 +00:00
27 changed files with 2878 additions and 457 deletions
Showing only changes of commit dd3c993146 - Show all commits

0
.codex Normal file
View file

7
.env
View file

@ -1,7 +0,0 @@
BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc
TZ=Europe/Moscow
ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923
LLAMA_API_URL=https://mirror.porno4free.ru/zovos-ai/
LLAMA_FORCE_DISABLE_THINKING=0
LLAMA_LOG_THINKING=1
WEBAPP_URL=https://uninterleaved-scrawnily-nicol.ngrok-free.dev

View file

@ -7,20 +7,19 @@ import mimetypes
import os import os
import random import random
import re import re
import sqlite3
import time import time
from io import BytesIO from io import BytesIO
from pathlib import Path
from typing import Any from typing import Any
import aiohttp import aiohttp
import psycopg2.extras
from aiogram.types import Message from aiogram.types import Message
from db import get_conn
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Основные настройки поведения и стиля бота редактируются здесь.
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/") LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or getattr(__import__("config"), "CHAT_HISTORY_DB_PATH", str(Path(__file__).resolve().with_name("chat_history.sqlite3")))
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот") BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
SKIP_TOKEN = "<skip>" SKIP_TOKEN = "<skip>"
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"} FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
@ -237,6 +236,7 @@ AUTONOMOUS_SIGNAL_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]") LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]")
WATCH_COMMAND_RE = re.compile(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL)
_reply_lock = asyncio.Lock() _reply_lock = asyncio.Lock()
_summary_lock = asyncio.Lock() _summary_lock = asyncio.Lock()
@ -244,133 +244,95 @@ _cached_bot_id: int | None = None
_cached_bot_username: str = "" _cached_bot_username: str = ""
def _connect_db() -> sqlite3.Connection: def _get_state_from_conn(conn, chat_id: int, key: str, default: str = "") -> str:
conn = sqlite3.connect(DB_PATH, timeout=30) cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
conn.row_factory = sqlite3.Row cur.execute(
conn.execute("PRAGMA busy_timeout = 30000") "SELECT value FROM chat_state WHERE chat_id = %s AND key = %s",
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), (chat_id, key),
).fetchone() )
row = cur.fetchone()
return row["value"] if row else default return row["value"] if row else default
def _set_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, value: str) -> None: def _set_state_from_conn(conn, chat_id: int, key: str, value: str) -> None:
conn.execute( cur = conn.cursor()
cur.execute(
""" """
INSERT INTO chat_state (chat_id, key, value) INSERT INTO chat_state (chat_id, key, value)
VALUES (?, ?, ?) VALUES (%s, %s, %s)
ON CONFLICT(chat_id, key) DO UPDATE SET value = excluded.value ON CONFLICT (chat_id, key) DO UPDATE SET value = EXCLUDED.value
""", """,
(chat_id, key, value), (chat_id, key, value),
) )
def _get_talk_state_from_conn( def _get_talk_state_from_conn(conn, chat_id: int, user_id: int, key: str, default: str = "") -> str:
conn: sqlite3.Connection, cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
chat_id: int, cur.execute(
user_id: int, "SELECT value FROM talk_state WHERE chat_id = %s AND user_id = %s AND key = %s",
key: str,
default: str = "",
) -> str:
row = conn.execute(
"SELECT value FROM talk_state WHERE chat_id = ? AND user_id = ? AND key = ?",
(chat_id, user_id, key), (chat_id, user_id, key),
).fetchone() )
row = cur.fetchone()
return row["value"] if row else default return row["value"] if row else default
def _set_talk_state_from_conn( def _set_talk_state_from_conn(conn, chat_id: int, user_id: int, key: str, value: str) -> None:
conn: sqlite3.Connection, cur = conn.cursor()
chat_id: int, cur.execute(
user_id: int,
key: str,
value: str,
) -> None:
conn.execute(
""" """
INSERT INTO talk_state (chat_id, user_id, key, value) INSERT INTO talk_state (chat_id, user_id, key, value)
VALUES (?, ?, ?, ?) VALUES (%s, %s, %s, %s)
ON CONFLICT(chat_id, user_id, key) DO UPDATE SET value = excluded.value ON CONFLICT (chat_id, user_id, key) DO UPDATE SET value = EXCLUDED.value
""", """,
(chat_id, user_id, key, value), (chat_id, user_id, key, value),
) )
def _init_db() -> None: def _init_db() -> None:
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) with get_conn() as conn:
with _connect_db() as conn: cur = conn.cursor()
conn.execute("PRAGMA journal_mode = WAL") cur.execute("""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_history ( CREATE TABLE IF NOT EXISTS chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGSERIAL PRIMARY KEY,
chat_id INTEGER NOT NULL, chat_id BIGINT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
text TEXT NOT NULL, text TEXT NOT NULL,
created_at REAL NOT NULL DEFAULT 0 created_at DOUBLE PRECISION NOT NULL DEFAULT 0
) )
""" """)
) cur.execute("ALTER TABLE chat_history ADD COLUMN IF NOT EXISTS created_at DOUBLE PRECISION NOT NULL DEFAULT 0")
if not _column_exists(conn, "chat_history", "created_at"): cur.execute("""
conn.execute("ALTER TABLE chat_history ADD COLUMN created_at REAL NOT NULL DEFAULT 0")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_state ( CREATE TABLE IF NOT EXISTS chat_state (
chat_id INTEGER NOT NULL, chat_id BIGINT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
value TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (chat_id, key) PRIMARY KEY (chat_id, key)
) )
""" """)
) cur.execute("""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS talk_history ( CREATE TABLE IF NOT EXISTS talk_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGSERIAL PRIMARY KEY,
chat_id INTEGER NOT NULL, chat_id BIGINT NOT NULL,
user_id INTEGER NOT NULL, user_id BIGINT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
text TEXT NOT NULL, text TEXT NOT NULL,
created_at REAL NOT NULL DEFAULT 0 created_at DOUBLE PRECISION NOT NULL DEFAULT 0
) )
""" """)
) cur.execute("""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS talk_state ( CREATE TABLE IF NOT EXISTS talk_state (
chat_id INTEGER NOT NULL, chat_id BIGINT NOT NULL,
user_id INTEGER NOT NULL, user_id BIGINT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
value TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (chat_id, user_id, key) PRIMARY KEY (chat_id, user_id, key)
) )
""" """)
) cur.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)")
conn.execute( cur.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)")
"CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)" cur.execute("CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id ON talk_history(chat_id, user_id, id)")
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)"
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id
ON talk_history(chat_id, user_id, id)
"""
)
conn.commit()
def _clean_text(text: str) -> str: def _clean_text(text: str) -> str:
@ -420,78 +382,59 @@ def push_message(
cleaned_text = _clean_text(text) cleaned_text = _clean_text(text)
if not cleaned_text: if not cleaned_text:
return return
with _connect_db() as conn: with get_conn() as conn:
cur = conn.cursor()
if user_id is None: if user_id is None:
conn.execute( cur.execute(
""" "INSERT INTO chat_history (chat_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s)",
INSERT INTO chat_history (chat_id, role, name, text, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(chat_id, role, name, cleaned_text, time.time()), (chat_id, role, name, cleaned_text, time.time()),
) )
else: else:
conn.execute( cur.execute(
""" "INSERT INTO talk_history (chat_id, user_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s, %s)",
INSERT INTO talk_history (chat_id, user_id, role, name, text, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(chat_id, user_id, role, name, cleaned_text, time.time()), (chat_id, user_id, role, name, cleaned_text, time.time()),
) )
conn.commit()
def _get_summary(chat_id: int, user_id: int | None = None) -> str: def _get_summary(chat_id: int, user_id: int | None = None) -> str:
with _connect_db() as conn: with get_conn() as conn:
if user_id is None: if user_id is None:
return _get_state_from_conn(conn, chat_id, "summary", "") return _get_state_from_conn(conn, chat_id, "summary", "")
return _get_talk_state_from_conn(conn, chat_id, user_id, "summary", "") return _get_talk_state_from_conn(conn, chat_id, user_id, "summary", "")
def _get_history_rows(chat_id: int, user_id: int | None = None) -> list[sqlite3.Row]: def _get_history_rows(chat_id: int, user_id: int | None = None) -> list:
with _connect_db() as conn: with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
if user_id is None: if user_id is None:
return conn.execute( cur.execute(
""" "SELECT id, role, name, text, created_at FROM chat_history WHERE chat_id = %s ORDER BY id ASC",
SELECT id, role, name, text, created_at
FROM chat_history
WHERE chat_id = ?
ORDER BY id ASC
""",
(chat_id,), (chat_id,),
).fetchall() )
return conn.execute( else:
""" cur.execute(
SELECT id, role, name, text, created_at "SELECT id, role, name, text, created_at FROM talk_history WHERE chat_id = %s AND user_id = %s ORDER BY id ASC",
FROM talk_history (chat_id, user_id),
WHERE chat_id = ? AND user_id = ? )
ORDER BY id ASC return cur.fetchall()
""",
(chat_id, user_id),
).fetchall()
def _latest_reply_stats(chat_id: int) -> tuple[float, int]: def _latest_reply_stats(chat_id: int) -> tuple[float, int]:
with _connect_db() as conn: with get_conn() as conn:
last_assistant = conn.execute( cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
""" cur.execute(
SELECT id, created_at "SELECT id, created_at FROM chat_history WHERE chat_id = %s AND role = 'assistant' ORDER BY id DESC LIMIT 1",
FROM chat_history
WHERE chat_id = ? AND role = 'assistant'
ORDER BY id DESC
LIMIT 1
""",
(chat_id,), (chat_id,),
).fetchone() )
last_assistant = cur.fetchone()
if not last_assistant: if not last_assistant:
return 0.0, 10_000 return 0.0, 10_000
user_messages_since_reply = conn.execute( cnt_cur = conn.cursor()
""" cnt_cur.execute(
SELECT COUNT(*) "SELECT COUNT(*) FROM chat_history WHERE chat_id = %s AND role = 'user' AND id > %s",
FROM chat_history
WHERE chat_id = ? AND role = 'user' AND id > ?
""",
(chat_id, last_assistant["id"]), (chat_id, last_assistant["id"]),
).fetchone()[0] )
user_messages_since_reply = cnt_cur.fetchone()[0]
return float(last_assistant["created_at"] or 0.0), int(user_messages_since_reply) return float(last_assistant["created_at"] or 0.0), int(user_messages_since_reply)
@ -501,23 +444,23 @@ def _store_summary_and_prune(
last_row_id: int, last_row_id: int,
user_id: int | None = None, user_id: int | None = None,
) -> None: ) -> None:
with _connect_db() as conn: with get_conn() as conn:
cur = conn.cursor()
if user_id is None: if user_id is None:
_set_state_from_conn(conn, chat_id, "summary", summary) _set_state_from_conn(conn, chat_id, "summary", summary)
conn.execute( cur.execute(
"DELETE FROM chat_history WHERE chat_id = ? AND id <= ?", "DELETE FROM chat_history WHERE chat_id = %s AND id <= %s",
(chat_id, last_row_id), (chat_id, last_row_id),
) )
else: else:
_set_talk_state_from_conn(conn, chat_id, user_id, "summary", summary) _set_talk_state_from_conn(conn, chat_id, user_id, "summary", summary)
conn.execute( cur.execute(
"DELETE FROM talk_history WHERE chat_id = ? AND user_id = ? AND id <= ?", "DELETE FROM talk_history WHERE chat_id = %s AND user_id = %s AND id <= %s",
(chat_id, user_id, last_row_id), (chat_id, user_id, last_row_id),
) )
conn.commit()
def _format_row_for_llm(row: sqlite3.Row) -> dict[str, str]: def _format_row_for_llm(row) -> dict[str, str]:
if row["role"] == "user": if row["role"] == "user":
return { return {
"role": "user", "role": "user",
@ -903,12 +846,13 @@ async def handle_photo_message(message: Message) -> bool:
is_image_document = bool(message.document and (message.document.mime_type or "").startswith("image/")) 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: if not (message.photo or is_image_document) or not message.from_user or message.from_user.is_bot:
return False return False
if message.caption and message.caption.startswith("/"): watch_match = WATCH_COMMAND_RE.match((message.caption or "").strip())
if not watch_match:
return False return False
chat_id = message.chat.id chat_id = message.chat.id
user_name = message.from_user.first_name or "кент" user_name = message.from_user.first_name or "кент"
caption = _clean_text(message.caption or "") caption = _clean_text(watch_match.group(1) or "")
logger.warning( logger.warning(
"Received image message for analysis. chat_id=%s user=%s has_photo=%s has_image_document=%s caption=%s", "Received image message for analysis. chat_id=%s user=%s has_photo=%s has_image_document=%s caption=%s",

106
DEPLOYMENT.md Normal file
View file

@ -0,0 +1,106 @@
# Инструкция по развертыванию бота
## 🚀 Быстрый старт
### 1. Клонирование и подготовка
```bash
git clone <репозиторий>
cd bot_tg
```
### 2. Настройка переменных окружения
Создайте файл `.env` с необходимыми переменными:
```env
BOT_TOKEN=your_telegram_bot_token
LLAMA_API_URL=http://your-llama-server:8080 # опционально
ODDS_API_KEY=your_odds_api_key # опционально для ставок
```
### 3. Запуск через Docker Compose
```bash
# Сборка и запуск
docker compose up --build -d
# Просмотр логов
docker compose logs -f bot
# Остановка
docker compose down
```
## 📁 Структура проекта
```
bot_tg/
├── main.py # основной файл бота
├── games/
│ ├── economy.py # экономическая система
│ ├── casino.py # казино
│ ├── betting.py # ставки на спорт
│ └── ...
├── AI/ # ИИ-модули
├── maket/ # макеты для мемов
├── db/ # базы данных (создаются автоматически)
├── Dockerfile
├── docker-compose.yaml
├── requirements.txt
└── .env # переменные окружения
```
## 🗄️ Базы данных
Автоматически создаются в папке `./db`:
- `penis_stats.sqlite3` - статистика игры /penis
- `chat_history.sqlite3` - история для ИИ-общения
- `polychaetsi_stats.json` - статистика слова "получается"
- `bets.sqlite3` - ставки на спорт
- `economy.sqlite3` - экономическая система (ЦБ, вклады, кредиты)
## 🏛️ Экономическая система
**Основная валюта:** сантиметры (см)
**Команды экономики:**
- `/balance` - баланс и статистика
- `/deposit [сумма] [дни]` - открыть вклад
- `/loan [сумма] [дни]` - взять кредит
- `/transfer @username [сумма]` - перевести деньги
- `/cb_stats` - статистика Центрального Банка
- `/central_bank` - детальная статистика ЦБ
- `/cb_rules` - правила работы ЦБ
**Центральный Банк:** децентрализованная система с капиталом 1000 см
## 🐛 Поиск проблем
### Проверка логов
```bash
docker compose logs bot
```
### Перезапуск бота
```bash
docker compose restart bot
```
### Проверка статуса контейнера
```bash
docker compose ps
```
## ⚠️ Важные моменты
1. **Все базы данных хранятся в `./db`** - эта папка монтируется в контейнер
2. **Бот работает в часовом поясе Europe/Moscow**
3. **Центральный Банк полностью автоматический** - никакого ручного управления
4. **Ежедневные выплаты за активность** - в 00:00 по МСК
5. **Налоги 13%** - автоматически поступают в ЦБ
## 🔧 Первоначальная настройка
1. Получите токен бота у @BotFather
2. Добавьте бота в чат (если нужен)
3. Настройте `.env` файл
4. Запустите через `docker compose up --build -d`
Бот готов к работе! 🎉

View file

@ -37,11 +37,12 @@ docker compose down
Для ставок нужен `ODDS_API_KEY` (бесплатно на https://the-odds-api.com). Для ставок нужен `ODDS_API_KEY` (бесплатно на https://the-odds-api.com).
Контейнер хранит состояние в `./db`: Контейнер хранит состояние в `./db`:
- `penis_stats.sqlite3` - `penis_stats.sqlite3` — статистика игры /penis
- `chat_history.sqlite3` - `chat_history.sqlite3` — история для ИИ-общения
- `polychaetsi_stats.json` - `polychaetsi_stats.json` — статистика слова "получается"
- `bets.sqlite3` - `bets.sqlite3` — ставки на спорт
- `nude_history.json` - `economy.sqlite3` — экономическая система (балансы, вклады, кредиты)
- `nude_history.json` — история запросов /nude
## Команды ## Команды
@ -63,6 +64,39 @@ docker compose down
- `/mybets` — мои активные ставки. - `/mybets` — мои активные ставки.
- `/svodka` — сводка СВО. - `/svodka` — сводка СВО.
## Экономика ZOV OS 💰
Основная валюта — сантиметры (см), получаемые из игры `/penis`.
**Команды экономики:**
- `/balance` — показать баланс и статистику
- `/deposit <сумма> <дни>` — открыть вклад по ставке ЦБ РФ
- `/loan <сумма> <дни>` — взять кредит под 15% годовых
- `/transfer @username <сумма>` — перевести деньги (лимит 20% от капитала)
- `/cb_stats` — статистика Центрального Банка
- `/central_bank` — детальная статистика ЦБ
- `/cb_rules` — правила работы ЦБ
- `/vanomasa` — полное обнуление всех балансов (админам)
**Фичи экономики:**
- Вклады по ставке ЦБ РФ (7.5% годовых)
- Кредиты под 15% годовых
- Налог 13% с ежедневного дохода
- Автоматическое регулирование инфляции
- Фоновый сбор налогов каждый час
- Заработок на сообщениях: 0.01 см за каждое сообщение
- Ежедневные выплаты в 00:00 по МСК за активность
**Центральный Банк ZOV OS 🏛️ (децентрализованный):**
- Учредительный капитал: 1000 см
- Полностью автоматическая работа без вмешательства
- Все налоги автоматически поступают в ЦБ
- Алгоритмическое регулирование инфляции
- Автоматическая эмиссия при дефляции
- Автоматическая скупка денег при инфляции
- Прозрачная статистика для всех пользователей
- Никакого ручного управления - система работает по правилам
## Макеты и шрифт ## Макеты и шрифт
- Шрифт `impact.ttf` лежит в корне. - Шрифт `impact.ttf` лежит в корне.

View file

@ -7,10 +7,9 @@ from zoneinfo import ZoneInfo
BASE_DIR = Path(__file__).parent BASE_DIR = Path(__file__).parent
FONT_PATH = BASE_DIR / "impact.ttf" FONT_PATH = BASE_DIR / "impact.ttf"
DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow") DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow")
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
# --- Настройки /penis --- # --- Настройки /penis ---
PENIS_DB_PATH = "/db/penis_stats.sqlite3"
CHAT_HISTORY_DB_PATH = "/db/chat_history.sqlite3"
PENIS_START_LENGTH = 10.0 PENIS_START_LENGTH = 10.0
PENIS_MIN_DELTA = 0.1 PENIS_MIN_DELTA = 0.1
PENIS_MAX_DELTA = 7.0 PENIS_MAX_DELTA = 7.0
@ -34,6 +33,7 @@ E621_API_KEY = os.getenv("E621_API_KEY", "")
# --- Ставки на матчи --- # --- Ставки на матчи ---
ODDS_API_KEY = os.getenv("ODDS_API_KEY", "") ODDS_API_KEY = os.getenv("ODDS_API_KEY", "")
ODDS_API_BASE = "https://api.the-odds-api.com/v4" ODDS_API_BASE = "https://api.the-odds-api.com/v4"
BET_MAX_AMOUNT = 3.0
ODDS_SPORTS = { ODDS_SPORTS = {
"epl": "soccer_epl", "epl": "soccer_epl",
"ucl": "soccer_uefa_champs_league", "ucl": "soccer_uefa_champs_league",
@ -43,8 +43,6 @@ ODDS_SPORTS = {
"ligue1": "soccer_france_ligue_one", "ligue1": "soccer_france_ligue_one",
"europa": "soccer_uefa_europa_league", "europa": "soccer_uefa_europa_league",
} }
BET_MAX_AMOUNT = 3.0
BET_DB_PATH = "/db/bets.sqlite3"
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно) ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
# --- Nude --- # --- Nude ---

35
db.py Normal file
View file

@ -0,0 +1,35 @@
import logging
import os
from contextlib import contextmanager
from typing import Generator
import psycopg2
import psycopg2.extras
import psycopg2.pool
logger = logging.getLogger(__name__)
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
_pool: psycopg2.pool.ThreadedConnectionPool | None = None
def _get_pool() -> psycopg2.pool.ThreadedConnectionPool:
global _pool
if _pool is None:
_pool = psycopg2.pool.ThreadedConnectionPool(2, 10, dsn=DATABASE_URL)
return _pool
@contextmanager
def get_conn() -> Generator[psycopg2.extensions.connection, None, None]:
pool = _get_pool()
conn = pool.getconn()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
pool.putconn(conn)

View file

@ -0,0 +1,38 @@
services:
webapp-api-test:
build:
context: .
dockerfile: Dockerfile.api
container_name: fenyabot-webapp-api-test
volumes:
- ./db:/db
env_file:
- .env
environment:
TZ: Europe/Moscow
PENIS_DB_PATH: /db/penis_stats.sqlite3
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
BET_DB_PATH: /db/bets.sqlite3
API_PORT: "8080"
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
ports:
- "18080:8080"
restart: unless-stopped
ngrok-test:
image: ngrok/ngrok:latest
container_name: fenyabot-ngrok-test
depends_on:
- webapp-api-test
environment:
NGROK_AUTHTOKEN: ${NGROK_AUTHTOKEN}
command:
- http
- webapp-api-test:8080
- --log=stdout
ports:
- "4041:4040"
restart: unless-stopped

View file

@ -1,4 +1,19 @@
services: services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: botdb
POSTGRES_USER: bot
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-botpass}
volumes:
- ./db/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bot -d botdb"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
bot: bot:
build: build:
context: . context: .
@ -8,13 +23,15 @@ services:
- .env - .env
environment: environment:
TZ: Europe/Moscow TZ: Europe/Moscow
PENIS_DB_PATH: /db/penis_stats.sqlite3 DATABASE_URL: postgresql://bot:${POSTGRES_PASSWORD:-botpass}@postgres:5432/botdb
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
BET_DB_PATH: /db/bets.sqlite3 NUDE_HISTORY_PATH: /db/nude_history.json
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/} LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0} LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1} LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
depends_on:
postgres:
condition: service_healthy
init: true init: true
restart: unless-stopped restart: unless-stopped
@ -28,14 +45,16 @@ services:
- .env - .env
environment: environment:
TZ: Europe/Moscow TZ: Europe/Moscow
PENIS_DB_PATH: /db/penis_stats.sqlite3 DATABASE_URL: postgresql://bot:${POSTGRES_PASSWORD:-botpass}@postgres:5432/botdb
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
BET_DB_PATH: /db/bets.sqlite3 NUDE_HISTORY_PATH: /db/nude_history.json
API_PORT: "8080" API_PORT: "8080"
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/} LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0} LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1} LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
webapp-frontend: webapp-frontend:
@ -58,5 +77,6 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
postgres_data:
certbot_etc: certbot_etc:
certbot_www: certbot_www:

View file

@ -1,13 +1,14 @@
import time as _time
import sqlite3
import logging import logging
from datetime import datetime, timedelta import time as _time
from pathlib import Path from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import aiohttp import aiohttp
import psycopg2
import psycopg2.extras
import config import config
from db import get_conn
from games.casino import get_user_length, update_user_length from games.casino import get_user_length, update_user_length
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -17,18 +18,16 @@ _sports_cache: tuple[float, list] | None = None
_last_viewed_sport_by_user: dict[int, str] = {} _last_viewed_sport_by_user: dict[int, str] = {}
_DRAW_NAMES = {"draw", "tie", "ничья"} _DRAW_NAMES = {"draw", "tie", "ничья"}
# Сколько дней хранить историю ставок
BET_HISTORY_DAYS = 30 BET_HISTORY_DAYS = 30
def _init_bets_db() -> None: def _init_bets_db() -> None:
Path(config.BET_DB_PATH).parent.mkdir(parents=True, exist_ok=True) with get_conn() as conn:
with sqlite3.connect(config.BET_DB_PATH) as conn: cur = conn.cursor()
conn.execute( cur.execute('''
"""
CREATE TABLE IF NOT EXISTS bets ( CREATE TABLE IF NOT EXISTS bets (
id INTEGER PRIMARY KEY AUTOINCREMENT, id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL, user_id BIGINT NOT NULL,
match_id TEXT NOT NULL, match_id TEXT NOT NULL,
sport TEXT NOT NULL, sport TEXT NOT NULL,
home_team TEXT NOT NULL, home_team TEXT NOT NULL,
@ -38,25 +37,15 @@ def _init_bets_db() -> None:
odds REAL NOT NULL, odds REAL NOT NULL,
status TEXT DEFAULT 'pending', status TEXT DEFAULT 'pending',
payout REAL DEFAULT 0, payout REAL DEFAULT 0,
created_ts INTEGER, created_ts BIGINT,
resolved_ts INTEGER resolved_ts BIGINT
) )
""" ''')
) cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_user ON bets(user_id, status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_user ON bets(user_id, status)") cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_match ON bets(match_id, status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_match ON bets(match_id, status)") cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_created ON bets(created_ts)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_created ON bets(created_ts)") cur.execute("ALTER TABLE bets ADD COLUMN IF NOT EXISTS payout REAL DEFAULT 0")
cur.execute("ALTER TABLE bets ADD COLUMN IF NOT EXISTS resolved_ts BIGINT")
# Миграция: добавить столбцы если их нет
cursor = conn.execute("PRAGMA table_info(bets)")
columns = {row[1] for row in cursor.fetchall()}
if "payout" not in columns:
conn.execute("ALTER TABLE bets ADD COLUMN payout REAL DEFAULT 0")
if "resolved_ts" not in columns:
conn.execute("ALTER TABLE bets ADD COLUMN resolved_ts INTEGER")
conn.commit()
try: try:
@ -79,7 +68,6 @@ def get_user_sport_context(user_id: int) -> str | None:
async def _fetch_available_sports() -> list[dict]: async def _fetch_available_sports() -> list[dict]:
"""Получить список доступных видов спорта из API."""
global _sports_cache global _sports_cache
now = _time.time() now = _time.time()
if _sports_cache and (now - _sports_cache[0]) < 3600: if _sports_cache and (now - _sports_cache[0]) < 3600:
@ -287,11 +275,9 @@ async def get_formatted_matches(sport_alias: str | None) -> str:
sport_key = config.ODDS_SPORTS[sport_alias] sport_key = config.ODDS_SPORTS[sport_alias]
matches = await fetch_matches(sport_key) matches = await fetch_matches(sport_key)
if not matches: if not matches:
# Попробуем проверить, существует ли вообще этот спорт
sports = await _fetch_available_sports() sports = await _fetch_available_sports()
valid_keys = {s.get("key") for s in sports} valid_keys = {s.get("key") for s in sports}
if sport_key not in valid_keys: if sport_key not in valid_keys:
# Ищем похожий ключ
suggestions = [s for s in sports if sport_alias in s.get("key", "").lower() suggestions = [s for s in sports if sport_alias in s.get("key", "").lower()
or sport_alias in s.get("title", "").lower()] or sport_alias in s.get("title", "").lower()]
if suggestions: if suggestions:
@ -304,10 +290,7 @@ async def get_formatted_matches(sport_alias: str | None) -> str:
f"Похожие виды спорта:\n{suggest_text}\n\n" f"Похожие виды спорта:\n{suggest_text}\n\n"
f"Обнови sport keys в config.py" f"Обнови sport keys в config.py"
) )
return ( return f"⚠️ Ключ {sport_key} не найден в API. Проверь ODDS_SPORTS в config.py"
f"⚠️ Ключ {sport_key} не найден в API. "
f"Проверь ODDS_SPORTS в config.py"
)
return format_matches(matches, sport_alias, _get_sport_label(sport_alias)) return format_matches(matches, sport_alias, _get_sport_label(sport_alias))
parts = [] parts = []
@ -335,16 +318,12 @@ def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: f
current = get_user_length(user_id) current = get_user_length(user_id)
if current is None: if current is None:
return "Сначала заведи счёт через /penis, братуха." return "Сначала заведи счёт через /penis, братуха."
if current <= 0: if current <= 0:
return "🚫 С кредитом ставки не принимаем." return "🚫 С кредитом ставки не принимаем."
if amount <= 0: if amount <= 0:
return "Ставка должна быть больше нуля." return "Ставка должна быть больше нуля."
if amount > config.BET_MAX_AMOUNT: if amount > config.BET_MAX_AMOUNT:
return f"Максимальная ставка — {config.BET_MAX_AMOUNT} см." return f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
if amount > current: if amount > current:
return f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер." return f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
@ -358,16 +337,18 @@ def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: f
now_ts = int(_time.time()) now_ts = int(_time.time())
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
conn.execute( cur = conn.cursor()
"""INSERT INTO bets (user_id, match_id, sport, home_team, away_team, cur.execute('''
chosen_team, amount, odds, status, payout, created_ts) INSERT INTO bets
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)""", (user_id, match_id, sport, home_team, away_team,
(user_id, match["id"], sport, match["home"], match["away"], chosen_team, amount, odds, status, payout, created_ts)
chosen_team, amount, team_odds, now_ts), VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', 0, %s)
) ''', (
conn.commit() user_id, match["id"], sport, match["home"], match["away"],
except sqlite3.Error: chosen_team, amount, team_odds, now_ts,
))
except psycopg2.Error:
logger.exception("Failed to place bet") logger.exception("Failed to place bet")
update_user_length(user_id, amount) update_user_length(user_id, amount)
return "Ошибка БД." return "Ошибка БД."
@ -385,13 +366,15 @@ def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: f
def get_user_bets(user_id: int) -> str: def get_user_bets(user_id: int) -> str:
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
conn.row_factory = sqlite3.Row cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
rows = conn.execute( cur.execute('''
"SELECT * FROM bets WHERE user_id = ? AND status = 'pending' ORDER BY created_ts DESC LIMIT 10", SELECT * FROM bets
(user_id,), WHERE user_id = %s AND status = 'pending'
).fetchall() ORDER BY created_ts DESC LIMIT 10
except sqlite3.Error: ''', (user_id,))
rows = cur.fetchall()
except psycopg2.Error:
return "Ошибка БД." return "Ошибка БД."
if not rows: if not rows:
@ -413,34 +396,26 @@ def get_user_bets(user_id: int) -> str:
def get_user_bet_history(user_id: int) -> str: def get_user_bet_history(user_id: int) -> str:
"""Полная история ставок за последний месяц.""" cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
cutoff_ts = int((_time.time()) - BET_HISTORY_DAYS * 86400)
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
conn.row_factory = sqlite3.Row cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
rows = conn.execute( cur.execute('''
"""SELECT * FROM bets SELECT * FROM bets
WHERE user_id = ? AND created_ts >= ? WHERE user_id = %s AND created_ts >= %s
ORDER BY created_ts DESC ORDER BY created_ts DESC LIMIT 50
LIMIT 50""", ''', (user_id, cutoff_ts))
(user_id, cutoff_ts), rows = cur.fetchall()
).fetchall() except psycopg2.Error:
except sqlite3.Error:
logger.exception("Failed to get bet history") logger.exception("Failed to get bet history")
return "Ошибка БД." return "Ошибка БД."
if not rows: if not rows:
return "📋 За последний месяц ставок не было." return "📋 За последний месяц ставок не было."
# Считаем статистику total_bet = total_won = total_lost = 0.0
total_bet = 0.0 wins = losses = pending = 0
total_won = 0.0
total_lost = 0.0
wins = 0
losses = 0
pending = 0
lines = [f"📋 История ставок за {BET_HISTORY_DAYS} дней:\n"] lines = [f"📋 История ставок за {BET_HISTORY_DAYS} дней:\n"]
for r in rows: for r in rows:
@ -453,7 +428,6 @@ def get_user_bet_history(user_id: int) -> str:
if status == "won": if status == "won":
wins += 1 wins += 1
# Для старых ставок payout может быть 0, пересчитываем
actual_payout = payout_raw if payout_raw > 0 else round(amount * odds_val, 1) actual_payout = payout_raw if payout_raw > 0 else round(amount * odds_val, 1)
total_won += actual_payout total_won += actual_payout
status_icon = "" status_icon = ""
@ -466,8 +440,7 @@ def get_user_bet_history(user_id: int) -> str:
else: else:
pending += 1 pending += 1
status_icon = "" status_icon = ""
potential = round(amount * odds_val, 1) result_text = f"ожидание → {round(amount * odds_val, 1):.1f} см"
result_text = f"ожидание → {potential:.1f} см"
created = "" created = ""
if r["created_ts"]: if r["created_ts"]:
@ -480,10 +453,8 @@ def get_user_bet_history(user_id: int) -> str:
f" 📅 {created}" f" 📅 {created}"
) )
# Итоговая статистика
net = total_won - total_lost net = total_won - total_lost
net_sign = "+" if net >= 0 else "" net_sign = "+" if net >= 0 else ""
lines.append("") lines.append("")
lines.append("━━━ 📊 СТАТИСТИКА ━━━") lines.append("━━━ 📊 СТАТИСТИКА ━━━")
lines.append(f"🎰 Всего ставок: {len(rows)}") lines.append(f"🎰 Всего ставок: {len(rows)}")
@ -495,29 +466,26 @@ def get_user_bet_history(user_id: int) -> str:
lines.append(f"🏆 Выиграно: +{total_won:.1f} см") lines.append(f"🏆 Выиграно: +{total_won:.1f} см")
lines.append(f"💸 Проиграно: -{total_lost:.1f} см") lines.append(f"💸 Проиграно: -{total_lost:.1f} см")
lines.append(f"📈 Итого: {net_sign}{net:.1f} см") lines.append(f"📈 Итого: {net_sign}{net:.1f} см")
if wins + losses > 0: if wins + losses > 0:
winrate = wins / (wins + losses) * 100 lines.append(f"📊 Винрейт: {wins / (wins + losses) * 100:.0f}%")
lines.append(f"📊 Винрейт: {winrate:.0f}%")
return "\n".join(lines) return "\n".join(lines)
def cleanup_old_bets() -> int: def cleanup_old_bets() -> int:
"""Удалить завершённые ставки старше BET_HISTORY_DAYS дней."""
cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400) cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
cursor = conn.execute( cur = conn.cursor()
"DELETE FROM bets WHERE status != 'pending' AND created_ts < ?", cur.execute(
"DELETE FROM bets WHERE status != 'pending' AND created_ts < %s",
(cutoff_ts,), (cutoff_ts,),
) )
conn.commit() deleted = cur.rowcount
deleted = cursor.rowcount
if deleted > 0: if deleted > 0:
logger.info(f"Cleaned up {deleted} old bets") logger.info(f"Cleaned up {deleted} old bets")
return deleted return deleted
except sqlite3.Error: except psycopg2.Error:
logger.exception("Failed to cleanup old bets") logger.exception("Failed to cleanup old bets")
return 0 return 0
@ -525,12 +493,11 @@ def cleanup_old_bets() -> int:
async def settle_bets() -> list[str]: async def settle_bets() -> list[str]:
notifications = [] notifications = []
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
conn.row_factory = sqlite3.Row cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
pending = conn.execute( cur.execute("SELECT DISTINCT match_id, sport FROM bets WHERE status = 'pending'")
"SELECT DISTINCT match_id, sport FROM bets WHERE status = 'pending'" pending = cur.fetchall()
).fetchall() except psycopg2.Error:
except sqlite3.Error:
return notifications return notifications
if not pending: if not pending:
@ -557,29 +524,22 @@ async def settle_bets() -> list[str]:
continue continue
for event in scores_data: for event in scores_data:
if event.get("id") != match_id: if event.get("id") != match_id or not event.get("completed"):
continue
if not event.get("completed"):
continue continue
scores = event.get("scores") scores = event.get("scores")
if not scores: if not scores:
continue continue
score_values = [(s.get("name", ""), int(s.get("score", 0))) for s in scores]
winner = None winner = None
max_score = -1
is_draw = False is_draw = False
score_values = []
for s in scores:
score_val = int(s.get("score", 0))
score_values.append((s.get("name", ""), score_val))
if len(score_values) >= 2 and score_values[0][1] == score_values[1][1]: if len(score_values) >= 2 and score_values[0][1] == score_values[1][1]:
is_draw = True is_draw = True
# Для ничьи ищем "Draw" среди ставок
winner = "Draw" winner = "Draw"
else: else:
max_score = -1
for name, score_val in score_values: for name, score_val in score_values:
if score_val > max_score: if score_val > max_score:
max_score = score_val max_score = score_val
@ -589,19 +549,18 @@ async def settle_bets() -> list[str]:
continue continue
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
conn.row_factory = sqlite3.Row cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
bets = conn.execute( cur.execute(
"SELECT * FROM bets WHERE match_id = ? AND status = 'pending'", "SELECT * FROM bets WHERE match_id = %s AND status = 'pending'",
(match_id,), (match_id,),
).fetchall() )
bets = cur.fetchall()
wcur = conn.cursor()
for bet in bets: for bet in bets:
chosen = bet["chosen_team"] chosen = bet["chosen_team"]
bet_won = False
if is_draw: if is_draw:
# Ставка на ничью выигрывает
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw" bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
else: else:
bet_won = chosen == winner bet_won = chosen == winner
@ -609,8 +568,8 @@ async def settle_bets() -> list[str]:
if bet_won: if bet_won:
winnings = round(bet["amount"] * bet["odds"], 1) winnings = round(bet["amount"] * bet["odds"], 1)
update_user_length(bet["user_id"], winnings) update_user_length(bet["user_id"], winnings)
conn.execute( wcur.execute(
"UPDATE bets SET status = 'won', payout = ?, resolved_ts = ? WHERE id = ?", "UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s",
(winnings, now_ts, bet["id"]), (winnings, now_ts, bet["id"]),
) )
notifications.append( notifications.append(
@ -618,26 +577,22 @@ async def settle_bets() -> list[str]:
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})" f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
) )
else: else:
conn.execute( wcur.execute(
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = ? WHERE id = ?", "UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s",
(now_ts, bet["id"]), (now_ts, bet["id"]),
) )
notifications.append( notifications.append(
f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см " f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см "
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})" f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
) )
conn.commit() except psycopg2.Error:
except sqlite3.Error:
logger.exception("Failed to settle bets") logger.exception("Failed to settle bets")
# Очистка старых ставок
cleanup_old_bets() cleanup_old_bets()
return notifications return notifications
async def debug_sports() -> str: async def debug_sports() -> str:
"""Диагностика: показать доступные виды спорта из API."""
if not config.ODDS_API_KEY: if not config.ODDS_API_KEY:
return "⚠️ ODDS_API_KEY не задан." return "⚠️ ODDS_API_KEY не задан."
@ -645,17 +600,14 @@ async def debug_sports() -> str:
if not sports: if not sports:
return "Не удалось получить список спортов от API." return "Не удалось получить список спортов от API."
# Показываем текущие ключи и проверяем их
current_keys = set(config.ODDS_SPORTS.values()) current_keys = set(config.ODDS_SPORTS.values())
valid_keys = {s.get("key") for s in sports} valid_keys = {s.get("key") for s in sports}
lines = ["🔍 Диагностика sport keys:\n"] lines = ["🔍 Диагностика sport keys:\n"]
for alias, key in config.ODDS_SPORTS.items(): for alias, key in config.ODDS_SPORTS.items():
status = "" if key in valid_keys else "НЕ НАЙДЕН" status = "" if key in valid_keys else "НЕ НАЙДЕН"
lines.append(f" {alias}{key} {status}") lines.append(f" {alias}{key} {status}")
# Показать доступные esports и football
lines.append("\n📋 Доступные eSports и футбол:") lines.append("\n📋 Доступные eSports и футбол:")
for s in sports: for s in sports:
key = s.get("key", "") key = s.get("key", "")

View file

@ -1,19 +1,20 @@
import random
import sqlite3
import logging import logging
import random
import psycopg2.extras
import config import config
from db import get_conn
from .economy import economy
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Символы слотов и их веса (чем выше вес — тем чаще выпадает)
SLOT_SYMBOLS = ["🍒", "🍋", "🔔", "💎", "7", "🍀"] SLOT_SYMBOLS = ["🍒", "🍋", "🔔", "💎", "7", "🍀"]
SLOT_WEIGHTS = [30, 25, 20, 12, 8, 5] # 🍒 чаще всего, 🍀 реже всего SLOT_WEIGHTS = [30, 25, 20, 12, 8, 5]
# Множители для выигрышей: {кол-во совпадений: (мин, макс)}
MULTIPLIERS = { MULTIPLIERS = {
2: (1.2, 1.8), # две одинаковых 2: (1.2, 1.8),
3: (2.0, 3.0), # три одинаковых (джекпот, редко) 3: (2.0, 3.0),
} }
CASINO_WIN_CHANCE = 0.35 CASINO_WIN_CHANCE = 0.35
@ -30,69 +31,53 @@ def spin_slots() -> tuple[list[str], int]:
counts = {} counts = {}
for s in reels: for s in reels:
counts[s] = counts.get(s, 0) + 1 counts[s] = counts.get(s, 0) + 1
max_match = max(counts.values()) return reels, max(counts.values())
return reels, max_match
def get_user_length(user_id: int) -> float | None: def get_user_length(user_id: int) -> float | None:
try: try:
with sqlite3.connect(config.PENIS_DB_PATH) as conn: with get_conn() as conn:
row = conn.execute( cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
"SELECT length FROM penis_stats WHERE user_id = ?", cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
(user_id,), row = cur.fetchone()
).fetchone() return float(row['balance']) if row else None
return float(row[0]) if row else None except Exception:
except sqlite3.Error: logger.exception("casino: failed to get balance")
logger.exception("casino: failed to get length")
return None return None
def update_user_length(user_id: int, delta: float) -> float | None: def update_user_length(user_id: int, delta: float) -> float | None:
try: try:
with sqlite3.connect(config.PENIS_DB_PATH) as conn: with get_conn() as conn:
row = conn.execute( cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
"SELECT length FROM penis_stats WHERE user_id = ?", cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
(user_id,), row = cur.fetchone()
).fetchone()
if row is None: if row is None:
return None return None
new_length = round(float(row[0]) + delta, 1) new_balance = round(float(row['balance']) + delta, 1)
# Не даём уйти ниже 0 при проигрыше if delta < 0 and new_balance < 0:
if delta < 0 and new_length < 0: new_balance = 0.0
new_length = 0.0 wcur = conn.cursor()
conn.execute( wcur.execute(
"UPDATE penis_stats SET length = ? WHERE user_id = ?", 'UPDATE user_balances SET balance = %s WHERE user_id = %s',
(new_length, user_id), (new_balance, user_id),
) )
conn.commit() return new_balance
return new_length except Exception:
except sqlite3.Error: logger.exception("casino: failed to update balance")
logger.exception("casino: failed to update length")
return None return None
def play_casino(user_id: int, bet: float) -> str: def play_casino(user_id: int, bet: float) -> str:
current = get_user_length(user_id) current = economy.get_user_balance(user_id)
if current is None:
return "Ты ещё ни разу не крутил /penis, братуха. Сначала заведи счёт."
# Проверяем отрицательный баланс
if current <= 0:
return "🚫 С кредитом в казино не впускают, братуха. Иди наращивай через /penis."
# Проверяем максимум ставки
if bet > config.CASINO_MAX_BET: if bet > config.CASINO_MAX_BET:
return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай." return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай."
if bet <= 0: if bet <= 0:
return "Ставка должна быть больше нуля, фраер." return "Ставка должна быть больше нуля, фраер."
# Проверяем хватает ли
if bet > current: if bet > current:
return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер." return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер."
# Крутим
reels, matches = spin_slots() reels, matches = spin_slots()
slots_display = " | ".join(reels) slots_display = " | ".join(reels)
@ -100,9 +85,8 @@ def play_casino(user_id: int, bet: float) -> str:
min_mult, max_mult = MULTIPLIERS[matches] min_mult, max_mult = MULTIPLIERS[matches]
multiplier = round(random.uniform(min_mult, max_mult), 1) multiplier = round(random.uniform(min_mult, max_mult), 1)
winnings = round(bet * multiplier, 1) winnings = round(bet * multiplier, 1)
new_length = update_user_length(user_id, winnings) economy.update_balance(user_id, winnings, 'casino_win', f'Выигрыш в казино x{multiplier}')
if new_length is None: new_balance = economy.get_user_balance(user_id)
return "Ошибка БД. Попробуй позже."
if matches == 3: if matches == 3:
return ( return (
@ -110,22 +94,21 @@ def play_casino(user_id: int, bet: float) -> str:
f"🎉 ДЖЕКПОТ!!! Три одинаковых!\n" f"🎉 ДЖЕКПОТ!!! Три одинаковых!\n"
f"Множитель: x{multiplier}\n" f"Множитель: x{multiplier}\n"
f"Выигрыш: +{winnings:.1f} см\n" f"Выигрыш: +{winnings:.1f} см\n"
f"📏 Текущий размер: {new_length:.1f} см" f"💰 Баланс: {new_balance:.1f} см"
) )
return ( return (
f"🎰 {slots_display}\n\n" f"🎰 {slots_display}\n\n"
f"✅ Выигрыш! Две совпали.\n" f"✅ Выигрыш! Две совпали.\n"
f"Множитель: x{multiplier}\n" f"Множитель: x{multiplier}\n"
f"Выигрыш: +{winnings:.1f} см\n" f"Выигрыш: +{winnings:.1f} см\n"
f"📏 Текущий размер: {new_length:.1f} см" f"💰 Баланс: {new_balance:.1f} см"
) )
else: else:
new_length = update_user_length(user_id, -bet) economy.update_balance(user_id, -bet, 'casino_loss', f'Проигрыш в казино -{bet} см')
if new_length is None: new_balance = economy.get_user_balance(user_id)
return "Ошибка БД. Попробуй позже."
return ( return (
f"🎰 {slots_display}\n\n" f"🎰 {slots_display}\n\n"
f"❌ Мимо, братуха.\n" f"❌ Мимо, братуха.\n"
f"Проигрыш: -{bet:.1f} см\n" f"Проигрыш: -{bet:.1f} см\n"
f"📏 Текущий размер: {new_length:.1f} см" f"💰 Баланс: {new_balance:.1f} см"
) )

669
games/economy.py Normal file
View file

@ -0,0 +1,669 @@
import logging
import random
from datetime import datetime, timedelta
from typing import Dict
import psycopg2.extras
from db import get_conn
logger = logging.getLogger(__name__)
class EconomyManager:
def __init__(self):
self.init_db()
def init_db(self):
with get_conn() as conn:
cur = conn.cursor()
cur.execute('''
CREATE TABLE IF NOT EXISTS user_balances (
user_id BIGINT PRIMARY KEY,
balance REAL DEFAULT 0.0,
daily_income REAL DEFAULT 0.0,
last_daily_reset DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS deposits (
id SERIAL PRIMARY KEY,
user_id BIGINT,
amount REAL,
interest_rate REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
matures_at TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS loans (
id SERIAL PRIMARY KEY,
user_id BIGINT,
amount REAL,
interest_rate REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
due_at TIMESTAMP,
is_repaid BOOLEAN DEFAULT FALSE
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS transactions (
id SERIAL PRIMARY KEY,
from_user_id BIGINT,
to_user_id BIGINT,
amount REAL,
transaction_type TEXT,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS taxes (
id SERIAL PRIMARY KEY,
user_id BIGINT,
amount REAL,
tax_date DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS economy_settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS daily_activity (
id SERIAL PRIMARY KEY,
user_id BIGINT,
activity_date DATE,
message_count INTEGER DEFAULT 0,
last_activity_time TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, activity_date)
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS central_bank (
id INTEGER PRIMARY KEY DEFAULT 1,
capital REAL DEFAULT 1000.0,
total_taxes_collected REAL DEFAULT 0.0,
total_inflation_adjustments REAL DEFAULT 0.0,
total_emissions REAL DEFAULT 0.0,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (id)
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS penis_stats (
user_id BIGINT PRIMARY KEY,
display_name TEXT NOT NULL DEFAULT '',
last_used_ts BIGINT
)
''')
default_settings = {
'central_bank_rate': '7.5',
'deposit_min_amount': '10.0',
'deposit_min_days': '7',
'loan_max_amount': '100.0',
'loan_interest_rate': '15.0',
'tax_rate': '13.0',
'transfer_limit_percent': '20.0',
'inflation_rate': '5.0',
'vanomasa_cooldown': '30',
'message_reward_rate': '0.01',
'last_activity_payout': '',
}
for key, value in default_settings.items():
cur.execute('''
INSERT INTO economy_settings (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO NOTHING
''', (key, value))
cur.execute('''
INSERT INTO central_bank (id, capital) VALUES (1, 1000.0)
ON CONFLICT (id) DO NOTHING
''')
def get_user_balance(self, user_id: int) -> float:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
result = cur.fetchone()
return result['balance'] if result else 0.0
def update_balance(self, user_id: int, amount: float, transaction_type: str, description: str = ""):
with get_conn() as conn:
cur = conn.cursor()
cur.execute('''
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
VALUES (%s, %s, 0.0, CURRENT_DATE)
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
''', (user_id, amount, amount))
cur.execute('''
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
VALUES (%s, %s, %s, %s, %s)
''', (user_id, user_id, amount, transaction_type, description))
logger.info(f"Balance updated for user {user_id}: +{amount} ({transaction_type})")
def transfer_money(self, from_user_id: int, to_user_id: int, amount: float, description: str = "") -> bool:
if amount <= 0:
return False
from_balance = self.get_user_balance(from_user_id)
if amount > from_balance * 0.2 or from_balance < amount:
return False
with get_conn() as conn:
cur = conn.cursor()
cur.execute(
'UPDATE user_balances SET balance = balance - %s WHERE user_id = %s',
(amount, from_user_id),
)
cur.execute('''
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
VALUES (%s, %s, 0.0, CURRENT_DATE)
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
''', (to_user_id, amount, amount))
cur.execute('''
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
VALUES (%s, %s, %s, 'transfer', %s)
''', (from_user_id, to_user_id, amount, description))
logger.info(f"Transfer: {amount} from user {from_user_id} to user {to_user_id}")
return True
def create_deposit(self, user_id: int, amount: float, days: int) -> bool:
if amount <= 0:
return False
balance = self.get_user_balance(user_id)
min_deposit = float(self.get_setting('deposit_min_amount'))
if amount < min_deposit or balance < amount:
return False
central_bank_rate = float(self.get_setting('central_bank_rate'))
matures_at = datetime.now() + timedelta(days=days)
with get_conn() as conn:
cur = conn.cursor()
cur.execute(
'UPDATE user_balances SET balance = balance - %s WHERE user_id = %s',
(amount, user_id),
)
cur.execute('''
UPDATE central_bank SET capital = capital + %s, last_updated = CURRENT_TIMESTAMP
WHERE id = 1
''', (amount,))
cur.execute('''
INSERT INTO deposits (user_id, amount, interest_rate, matures_at)
VALUES (%s, %s, %s, %s)
''', (user_id, amount, central_bank_rate, matures_at))
logger.info(f"Deposit created: user {user_id}, amount {amount}, rate {central_bank_rate}%")
return True
def create_loan(self, user_id: int, amount: float, days: int) -> bool:
if amount <= 0:
return False
max_loan = float(self.get_setting('loan_max_amount'))
loan_rate = float(self.get_setting('loan_interest_rate'))
if amount > max_loan:
return False
due_at = datetime.now() + timedelta(days=days)
with get_conn() as conn:
cur = conn.cursor()
cur.execute('''
UPDATE central_bank SET capital = capital - %s, last_updated = CURRENT_TIMESTAMP
WHERE id = 1
''', (amount,))
cur.execute('''
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
VALUES (%s, %s, 0.0, CURRENT_DATE)
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
''', (user_id, amount, amount))
cur.execute('''
INSERT INTO loans (user_id, amount, interest_rate, due_at)
VALUES (%s, %s, %s, %s)
''', (user_id, amount, loan_rate, due_at))
logger.info(f"Loan created: user {user_id}, amount {amount}, rate {loan_rate}%")
return True
def repay_loan(self, user_id: int, loan_id: int) -> bool:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
'SELECT amount, interest_rate, is_repaid FROM loans WHERE id = %s AND user_id = %s',
(loan_id, user_id),
)
loan = cur.fetchone()
if not loan or loan['is_repaid']:
return False
total_repayment = loan['amount'] * (1 + loan['interest_rate'] / 100)
balance = self.get_user_balance(user_id)
if balance < total_repayment:
return False
wcur = conn.cursor()
wcur.execute(
'UPDATE user_balances SET balance = balance - %s WHERE user_id = %s',
(total_repayment, user_id),
)
wcur.execute('''
UPDATE central_bank SET capital = capital + %s, last_updated = CURRENT_TIMESTAMP
WHERE id = 1
''', (total_repayment,))
wcur.execute('UPDATE loans SET is_repaid = TRUE WHERE id = %s', (loan_id,))
logger.info(f"Loan repaid: user {user_id}, loan {loan_id}, amount {total_repayment}")
return True
def collect_daily_taxes(self):
tax_rate = float(self.get_setting('tax_rate')) / 100
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('''
SELECT user_id, daily_income FROM user_balances
WHERE daily_income > 0 AND (last_daily_reset != CURRENT_DATE OR last_daily_reset IS NULL)
''')
users = cur.fetchall()
total_taxes_collected = 0.0
wcur = conn.cursor()
for row in users:
user_id = row['user_id']
daily_income = row['daily_income']
tax_amount = daily_income * tax_rate
if tax_amount <= 0:
continue
wcur.execute('''
UPDATE user_balances
SET balance = balance - %s, daily_income = 0, last_daily_reset = CURRENT_DATE
WHERE user_id = %s
''', (tax_amount, user_id))
wcur.execute(
'INSERT INTO taxes (user_id, amount, tax_date) VALUES (%s, %s, CURRENT_DATE)',
(user_id, tax_amount),
)
total_taxes_collected += tax_amount
if total_taxes_collected > 0:
wcur.execute('''
UPDATE central_bank
SET capital = capital + %s, total_taxes_collected = total_taxes_collected + %s
WHERE id = 1
''', (total_taxes_collected, total_taxes_collected))
logger.info(f"Daily taxes collected: {total_taxes_collected:.2f} cm from {len(users)} users")
def count_message(self, user_id: int):
today = datetime.now().strftime('%Y-%m-%d')
with get_conn() as conn:
cur = conn.cursor()
cur.execute('''
INSERT INTO daily_activity (user_id, activity_date, message_count, last_activity_time)
VALUES (%s, %s, 1, CURRENT_TIMESTAMP)
ON CONFLICT (user_id, activity_date) DO UPDATE SET
message_count = daily_activity.message_count + 1,
last_activity_time = CURRENT_TIMESTAMP
''', (user_id, today))
def pay_daily_activity_rewards(self) -> int:
reward_rate = float(self.get_setting('message_reward_rate'))
last_payout = self.get_setting('last_activity_payout')
today = datetime.now().strftime('%Y-%m-%d')
if last_payout == today:
return 0
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('''
SELECT user_id, message_count FROM daily_activity
WHERE activity_date = %s AND message_count > 0
''', (yesterday,))
users = cur.fetchall()
total_paid = 0.0
wcur = conn.cursor()
for row in users:
user_id = row['user_id']
message_count = row['message_count']
reward = message_count * reward_rate
if reward <= 0:
continue
wcur.execute('''
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
VALUES (%s, %s, 0.0, CURRENT_DATE)
ON CONFLICT (user_id) DO UPDATE SET
balance = user_balances.balance + %s,
daily_income = user_balances.daily_income + %s
''', (user_id, reward, reward, reward))
wcur.execute('''
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
VALUES (%s, %s, %s, 'activity_reward', %s)
''', (user_id, user_id, reward, f'Вознаграждение за {message_count} сообщений'))
total_paid += reward
wcur.execute(
'UPDATE economy_settings SET value = %s WHERE key = %s',
(today, 'last_activity_payout'),
)
logger.info(f"Activity rewards paid: {total_paid:.2f} cm to {len(users)} users")
return len(users)
def get_central_bank_stats(self) -> Dict:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('''
SELECT capital, total_taxes_collected, total_inflation_adjustments, total_emissions
FROM central_bank WHERE id = 1
''')
result = cur.fetchone()
if result:
return {
'capital': result['capital'],
'total_taxes_collected': result['total_taxes_collected'],
'total_inflation_adjustments': result['total_inflation_adjustments'],
'total_emissions': result['total_emissions'],
}
return {
'capital': 1000.0,
'total_taxes_collected': 0.0,
'total_inflation_adjustments': 0.0,
'total_emissions': 0.0,
}
def update_central_bank(self, capital_change: float, transaction_type: str, description: str = ""):
with get_conn() as conn:
cur = conn.cursor()
cur.execute('''
UPDATE central_bank SET capital = capital + %s, last_updated = CURRENT_TIMESTAMP
WHERE id = 1
''', (capital_change,))
if transaction_type == 'tax':
cur.execute('''
UPDATE central_bank
SET total_taxes_collected = total_taxes_collected + %s WHERE id = 1
''', (abs(capital_change),))
elif transaction_type == 'inflation':
cur.execute('''
UPDATE central_bank
SET total_inflation_adjustments = total_inflation_adjustments + %s WHERE id = 1
''', (abs(capital_change),))
elif transaction_type == 'emission':
cur.execute('''
UPDATE central_bank
SET total_emissions = total_emissions + %s WHERE id = 1
''', (abs(capital_change),))
logger.info(f"Central bank updated: {capital_change} ({transaction_type})")
def emit_money(self, amount: float, reason: str = "") -> bool:
if self.get_central_bank_stats()['capital'] < amount:
return False
self.update_central_bank(-amount, 'emission', reason)
logger.info(f"Money emitted: {amount} cm ({reason})")
return True
def vanomasa(self) -> bool:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT value FROM economy_settings WHERE key = %s", ('last_vanomasa',))
last_vanomasa = cur.fetchone()
if last_vanomasa and last_vanomasa['value']:
try:
last_date = datetime.strptime(last_vanomasa['value'], '%Y-%m-%d')
cooldown_days = int(self.get_setting('vanomasa_cooldown'))
if datetime.now() - last_date < timedelta(days=cooldown_days):
return False
except ValueError:
pass
today = datetime.now().strftime('%Y-%m-%d')
wcur = conn.cursor()
wcur.execute('UPDATE user_balances SET balance = 0, daily_income = 0')
wcur.execute('''
INSERT INTO economy_settings (key, value, updated_at) VALUES (%s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
''', ('last_vanomasa', today))
logger.warning("VANOMASA: All balances reset to zero!")
return True
def get_setting(self, key: str) -> str:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('SELECT value FROM economy_settings WHERE key = %s', (key,))
result = cur.fetchone()
return result['value'] if result else '0'
def update_setting(self, key: str, value: str):
with get_conn() as conn:
cur = conn.cursor()
cur.execute('''
INSERT INTO economy_settings (key, value, updated_at) VALUES (%s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
''', (key, value))
def get_user_stats(self, user_id: int) -> Dict:
today = datetime.now().strftime('%Y-%m-%d')
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
row = cur.fetchone()
balance = row['balance'] if row else 0.0
cur.execute('''
SELECT COUNT(*) AS cnt, COALESCE(SUM(amount), 0) AS total
FROM deposits WHERE user_id = %s AND is_active = TRUE
''', (user_id,))
deposits = cur.fetchone()
cur.execute('''
SELECT COUNT(*) AS cnt, COALESCE(SUM(amount), 0) AS total
FROM loans WHERE user_id = %s AND is_repaid = FALSE
''', (user_id,))
loans = cur.fetchone()
cur.execute(
'SELECT message_count FROM daily_activity WHERE user_id = %s AND activity_date = %s',
(user_id, today),
)
today_row = cur.fetchone()
cur.execute(
'SELECT message_count FROM daily_activity WHERE user_id = %s AND activity_date = %s',
(user_id, yesterday),
)
yesterday_row = cur.fetchone()
return {
'balance': balance,
'active_deposits_count': deposits['cnt'] or 0,
'active_deposits_sum': deposits['total'] or 0.0,
'active_loans_count': loans['cnt'] or 0,
'active_loans_sum': loans['total'] or 0.0,
'today_messages': today_row['message_count'] if today_row else 0,
'yesterday_messages': yesterday_row['message_count'] if yesterday_row else 0,
}
def regulate_inflation(self):
inflation_rate = float(self.get_setting('inflation_rate'))
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('SELECT SUM(balance) AS total_balance, COUNT(*) AS user_count FROM user_balances')
result = cur.fetchone()
total_supply = result['total_balance'] or 0.0
cur.execute('SELECT capital FROM central_bank WHERE id = 1')
cb_result = cur.fetchone()
cb_capital = cb_result['capital'] if cb_result else 1000.0
if inflation_rate > 10.0 and total_supply > 1000 and cb_capital > 100:
buyback_amount = min(total_supply * 0.05, cb_capital * 0.1)
self.update_central_bank(-buyback_amount, 'inflation', f'Anti-inflation: {buyback_amount:.2f} cm')
new_tax_rate = min(20.0, 13.0 + (inflation_rate - 10.0) * 0.5)
self.update_setting('tax_rate', str(new_tax_rate))
logger.info(f"Inflation regulation: bought back {buyback_amount:.2f} cm, tax={new_tax_rate}%")
elif inflation_rate < 2.0 and total_supply < 500 and cb_capital > 200:
emission_amount = cb_capital * 0.05
self.update_central_bank(-emission_amount, 'emission', f'Stimulus: {emission_amount:.2f} cm')
new_tax_rate = max(10.0, 13.0 - (2.0 - inflation_rate) * 1.5)
self.update_setting('tax_rate', str(new_tax_rate))
logger.info(f"Economic stimulus: emitted {emission_amount:.2f} cm, tax={new_tax_rate}%")
def play_penis(self, user_id: int, display_name: str, now_ts: int,
start_length: float, cooldown_seconds: int,
min_delta: float, max_delta: float) -> dict:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
row = cur.fetchone()
current_balance = row['balance'] if row else start_length
cur.execute(
'SELECT last_used_ts, display_name FROM penis_stats WHERE user_id = %s',
(user_id,),
)
ps_row = cur.fetchone()
last_used_ts = ps_row['last_used_ts'] if ps_row else None
if last_used_ts is not None:
next_ts = int(last_used_ts) + cooldown_seconds
if now_ts < next_ts:
if ps_row and ps_row['display_name'] != display_name:
wcur = conn.cursor()
wcur.execute(
'UPDATE penis_stats SET display_name = %s WHERE user_id = %s',
(display_name, user_id),
)
return {'allowed': False, 'left_seconds': next_ts - now_ts}
delta = round(random.uniform(min_delta, max_delta), 1)
sign = random.choice([-1, 1])
new_balance = round(max(0.1, current_balance + sign * delta), 1)
wcur = conn.cursor()
wcur.execute('''
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
VALUES (%s, %s, 0.0, CURRENT_DATE)
ON CONFLICT (user_id) DO UPDATE SET balance = %s
''', (user_id, new_balance, new_balance))
sign_label = f'+{delta:.1f}' if sign > 0 else f'-{delta:.1f}'
wcur.execute('''
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
VALUES (%s, %s, %s, 'penis_game', %s)
''', (user_id, user_id, sign * delta, f'Игра /penis: {sign_label} см'))
if ps_row is None:
wcur.execute(
'INSERT INTO penis_stats (user_id, display_name, last_used_ts) VALUES (%s, %s, %s)',
(user_id, display_name, now_ts),
)
else:
wcur.execute(
'UPDATE penis_stats SET display_name = %s, last_used_ts = %s WHERE user_id = %s',
(display_name, now_ts, user_id),
)
return {'allowed': True, 'new_balance': new_balance, 'delta': delta, 'sign': sign}
def get_penis_top(self, limit: int) -> list:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('''
SELECT ub.user_id, ps.display_name, ub.balance
FROM user_balances ub
INNER JOIN penis_stats ps ON ub.user_id = ps.user_id
ORDER BY ub.balance DESC, ub.user_id ASC
LIMIT %s
''', (limit,))
rows = cur.fetchall()
return [{'user_id': r['user_id'], 'display_name': r['display_name'], 'balance': r['balance']}
for r in rows]
def process_matured_deposits(self) -> int:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('''
SELECT id, user_id, amount, interest_rate
FROM deposits
WHERE is_active = TRUE AND matures_at <= NOW()
''')
matured = cur.fetchall()
paid = 0
wcur = conn.cursor()
for row in matured:
dep_id = row['id']
user_id = row['user_id']
amount = row['amount']
rate = row['interest_rate']
payout = round(amount * (1 + rate / 100 * 1 / 365 * 30), 2)
wcur.execute('''
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
VALUES (%s, %s, 0.0, CURRENT_DATE)
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
''', (user_id, payout, payout))
wcur.execute('''
UPDATE central_bank SET capital = capital - %s, last_updated = CURRENT_TIMESTAMP
WHERE id = 1
''', (payout,))
wcur.execute('UPDATE deposits SET is_active = FALSE WHERE id = %s', (dep_id,))
wcur.execute('''
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
VALUES (0, %s, %s, 'deposit_payout', %s)
''', (user_id, payout, f'Выплата вклада #{dep_id}: {amount:.1f} + проценты = {payout:.2f} см'))
paid += 1
if paid:
logger.info(f"Matured deposits paid out: {paid}")
return paid
economy = EconomyManager()

View file

@ -18,6 +18,7 @@ CAPTIONS = [
"Шо ты блять псюнчик решил свой подергать?", "Шо ты блять псюнчик решил свой подергать?",
"Решил подрочить сука?", "Решил подрочить сука?",
"гнойный писюн у тебя кнч, да", "гнойный писюн у тебя кнч, да",
"Ух нихуя, это ШПАЧИХА!?",
] ]
LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts" LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts"

412
main.py
View file

@ -1,5 +1,5 @@
# Системные импорты # Системные импорты
import asyncio, json, logging, os, re, random, sqlite3, threading import asyncio, json, logging, os, re, random, threading
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from io import BytesIO from io import BytesIO
@ -39,6 +39,7 @@ from games.betting import (
resolve_match_outcome, resolve_match_outcome,
settle_bets, settle_bets,
) )
from games.economy import economy
from zparser import get_military_data from zparser import get_military_data
import config import config
@ -58,6 +59,12 @@ def _is_supported_image_message(message: Message) -> bool:
) )
) )
def _is_watch_image_message(message: Message) -> bool:
if not _is_supported_image_message(message):
return False
caption = (message.caption or "").strip()
return bool(re.match(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s|$)", caption, flags=re.IGNORECASE))
async def handle_unmatched_message_debug(message: Message): async def handle_unmatched_message_debug(message: Message):
logger.warning( logger.warning(
@ -132,23 +139,6 @@ class LessonSlot:
# --- Инициализация и вспомогательные функции --- # --- Инициализация и вспомогательные функции ---
def init_penis_db() -> None:
"""Инициализация БД для миниигры."""
Path(config.PENIS_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS penis_stats (
user_id INTEGER PRIMARY KEY,
display_name TEXT NOT NULL,
length REAL NOT NULL,
last_used_ts INTEGER
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_penis_stats_length ON penis_stats(length DESC)")
conn.commit()
def init_polychaetsi_storage() -> None: def init_polychaetsi_storage() -> None:
"""Инициализация json-хранилища статистики слова 'получается'.""" """Инициализация json-хранилища статистики слова 'получается'."""
path = config.POLYCHAETSI_STATS_PATH path = config.POLYCHAETSI_STATS_PATH
@ -723,61 +713,29 @@ def play_penis(user_id: int, user_name: str | None = None, now: datetime | None
display_name = user_name.strip() if user_name and user_name.strip() else f"user_{user_id}" display_name = user_name.strip() if user_name and user_name.strip() else f"user_{user_id}"
try: try:
with sqlite3.connect(config.PENIS_DB_PATH) as conn: result = economy.play_penis(
conn.row_factory = sqlite3.Row user_id, display_name, now_ts,
row = conn.execute( config.PENIS_START_LENGTH, config.PENIS_COOLDOWN_SECONDS,
"SELECT length, last_used_ts, display_name FROM penis_stats WHERE user_id = ?", config.PENIS_MIN_DELTA, config.PENIS_MAX_DELTA,
(user_id,), )
).fetchone() except Exception:
if row is None:
current_length = config.PENIS_START_LENGTH
last_used_ts = None
else:
current_length = float(row["length"])
last_used_ts = row["last_used_ts"]
stored_name = row["display_name"]
if not user_name and isinstance(stored_name, str) and stored_name.strip():
display_name = stored_name.strip()
if last_used_ts is not None:
next_ts = int(last_used_ts) + config.PENIS_COOLDOWN_SECONDS
if now_ts < next_ts:
if row is not None and row["display_name"] != display_name:
conn.execute(
"UPDATE penis_stats SET display_name = ? WHERE user_id = ?",
(display_name, user_id),
)
left_seconds = next_ts - now_ts
hours = left_seconds // 3600
minutes = (left_seconds % 3600) // 60
conn.commit()
return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин."
delta = round(random.uniform(config.PENIS_MIN_DELTA, config.PENIS_MAX_DELTA), 1)
sign = random.choice([-1, 1])
new_length = round(max(0.1, current_length + sign * delta), 1)
if row is None:
conn.execute(
"INSERT INTO penis_stats(user_id, display_name, length, last_used_ts) VALUES(?, ?, ?, ?)",
(user_id, display_name, new_length, now_ts),
)
else:
conn.execute(
"UPDATE penis_stats SET display_name = ?, length = ?, last_used_ts = ? WHERE user_id = ?",
(display_name, new_length, now_ts, user_id),
)
conn.commit()
except sqlite3.Error:
logger.exception("Failed to process /penis") logger.exception("Failed to process /penis")
return False, "Ошибка БД. Попробуй позже." return False, "Ошибка БД. Попробуй позже."
if not result['allowed']:
left = result['left_seconds']
hours = left // 3600
minutes = (left % 3600) // 60
return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин."
sign = result['sign']
delta = result['delta']
new_balance = result['new_balance']
sign_text = "+" if sign > 0 else "-" sign_text = "+" if sign > 0 else "-"
return ( return (
True, True,
f"📏 Изменение: {sign_text}{delta:.1f} см\n" f"📏 Изменение: {sign_text}{delta:.1f} см\n"
f"Текущая длина: {new_length:.1f} см\n" f"Текущая длина: {new_balance:.1f} см\n"
"Следующая попытка через 24 часа.", "Следующая попытка через 24 часа.",
) )
@ -790,18 +748,8 @@ def format_penis_user_name(user: types.User) -> str:
def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str: def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str:
try: try:
with sqlite3.connect(config.PENIS_DB_PATH) as conn: rows = economy.get_penis_top(limit)
conn.row_factory = sqlite3.Row except Exception:
rows = conn.execute(
"""
SELECT user_id, display_name, length
FROM penis_stats
ORDER BY length DESC, user_id ASC
LIMIT ?
""",
(limit,),
).fetchall()
except sqlite3.Error:
logger.exception("Failed to build /top_penis") logger.exception("Failed to build /top_penis")
return "Не удалось загрузить топ. Ошибка БД." return "Не удалось загрузить топ. Ошибка БД."
@ -812,11 +760,10 @@ def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str:
lines = ["🏆 Топ по длине:"] lines = ["🏆 Топ по длине:"]
for index, row in enumerate(rows, start=1): for index, row in enumerate(rows, start=1):
user_id = int(row["user_id"]) user_id = int(row["user_id"])
length = float(row["length"]) balance = float(row["balance"])
name = row["display_name"] if isinstance(row["display_name"], str) else f"user_{user_id}" name = str(row["display_name"]).strip() or f"user_{user_id}"
name = name.strip() if name.strip() else f"user_{user_id}"
prefix = medals.get(index, f"{index}.") prefix = medals.get(index, f"{index}.")
lines.append(f"{prefix} {name}{length:.1f} см") lines.append(f"{prefix} {name}{balance:.1f} см")
return "\n".join(lines) return "\n".join(lines)
def _load_polychaetsi_stats() -> dict: def _load_polychaetsi_stats() -> dict:
@ -1195,10 +1142,19 @@ _autoreply_disabled_chats: set[int] = set()
async def handle_keywords(message: Message): async def handle_keywords(message: Message):
if not message.text or message.text.startswith('/'): if not message.text or message.text.startswith('/'):
return return
# Считаем сообщение для экономической системы
if message.from_user:
await asyncio.to_thread(economy.count_message, message.from_user.id)
text_lower = message.text.lower() text_lower = message.text.lower()
chat_id = message.chat.id chat_id = message.chat.id
user_name = (message.from_user.first_name or "кент") if message.from_user else "кент" user_name = (message.from_user.first_name or "кент") if message.from_user else "кент"
# /ebalnik должен глушить все авто-реакции в чате, включая медиа/фото-ветки.
if chat_id in _autoreply_disabled_chats:
return
if any(re.search(rf'\b{word}\b', text_lower) for word in config.KEYWORDS): if any(re.search(rf'\b{word}\b', text_lower) for word in config.KEYWORDS):
audio_path = config.BASE_DIR / "maket" / "Radiohead - Creep.mp3" audio_path = config.BASE_DIR / "maket" / "Radiohead - Creep.mp3"
if audio_path.exists(): if audio_path.exists():
@ -1225,9 +1181,6 @@ async def handle_keywords(message: Message):
# Записываем каждое обычное сообщение в историю чата # Записываем каждое обычное сообщение в историю чата
await asyncio.to_thread(push_message, chat_id, "user", user_name, message.text) await asyncio.to_thread(push_message, chat_id, "user", user_name, message.text)
if chat_id in _autoreply_disabled_chats:
return
await handle_chat_message(message, store_message=False, allow_autonomous=True) await handle_chat_message(message, store_message=False, allow_autonomous=True)
async def handle_penis_casino_cmd(message: Message): async def handle_penis_casino_cmd(message: Message):
@ -1246,6 +1199,244 @@ async def handle_penis_casino_cmd(message: Message):
result = await asyncio.to_thread(play_casino, message.from_user.id, bet) result = await asyncio.to_thread(play_casino, message.from_user.id, bet)
await message.reply(result, parse_mode=None) await message.reply(result, parse_mode=None)
# --- Экономические команды ---
async def handle_balance_cmd(message: Message):
"""Показать баланс пользователя"""
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
balance = economy.get_user_balance(message.from_user.id)
stats = await asyncio.to_thread(economy.get_user_stats, message.from_user.id)
cb_stats = await asyncio.to_thread(economy.get_central_bank_stats)
text = f"""💰 **Твой баланс:** {balance:.1f} см
📊 **Статистика:**
Активные вклады: {stats['active_deposits_count']} шт. ({stats['active_deposits_sum']:.1f} см)
Активные кредиты: {stats['active_loans_count']} шт. ({stats['active_loans_sum']:.1f} см)
Сообщений сегодня: {stats['today_messages']} шт.
Сообщений вчера: {stats['yesterday_messages']} шт.
Ожидаемый доход: {stats['yesterday_messages'] * 0.01:.2f} см
🏛 **Центральный Банк:**
Капитал ЦБ: {cb_stats['capital']:.1f} см
Собрано налогов: {cb_stats['total_taxes_collected']:.1f} см
💡 **Команды экономики:**
/balance - показать баланс
/deposit [сумма] [дни] - открыть вклад
/loans - показать кредиты
/loan [сумма] [дни] - взять кредит
/repay [ID] - погасить кредит
/transfer @username [сумма] - перевести деньги
/cb_stats - статистика ЦБ
/vanomasa - полное обнуление (для админов)
💬 **Заработок на сообщениях:**
0.01 см за каждое сообщение
Выплата в 00:00 по МСК
"""
await message.answer(text, parse_mode=None)
async def handle_deposit_cmd(message: Message):
"""Открыть вклад"""
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
parts = message.text.split(maxsplit=2)
if len(parts) < 3:
await message.reply("Формат: /deposit [сумма] [дни]\nПример: /deposit 50 30", parse_mode=None)
return
try:
amount = float(parts[1].replace(",", "."))
days = int(parts[2])
except ValueError:
await message.reply("Сумма должна быть числом, дни - целым числом.", parse_mode=None)
return
if amount <= 0 or days <= 0:
await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
return
success = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days)
if success:
rate = economy.get_setting('central_bank_rate')
await message.reply(f"✅ Вклад открыт!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None)
else:
await message.reply("Не удалось открыть вклад. Проверь баланс и минимальную сумму.", parse_mode=None)
async def handle_loans_cmd(message: Message):
"""Показать кредиты"""
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
# Здесь можно добавить логику показа кредитов пользователя
await message.reply("📋 Твои кредиты:\n\n(функция в разработке)", parse_mode=None)
async def handle_loan_cmd(message: Message):
"""Взять кредит"""
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
parts = message.text.split(maxsplit=2)
if len(parts) < 3:
await message.reply("Формат: /loan [сумма] [дни]\nПример: /loan 50 30", parse_mode=None)
return
try:
amount = float(parts[1].replace(",", "."))
days = int(parts[2])
except ValueError:
await message.reply("Сумма должна быть числом, дни - целым числом.", parse_mode=None)
return
if amount <= 0 or days <= 0:
await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
return
success = await asyncio.to_thread(economy.create_loan, message.from_user.id, amount, days)
if success:
rate = economy.get_setting('loan_interest_rate')
await message.reply(f"✅ Кредит выдан!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None)
else:
await message.reply("Не удалось взять кредит. Проверь лимиты.", parse_mode=None)
async def handle_transfer_cmd(message: Message):
"""Перевод денег другому пользователю"""
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
parts = message.text.split(maxsplit=2)
if len(parts) < 3:
await message.reply("Формат: /transfer @username [сумма]\nПример: /transfer @user123 10.5", parse_mode=None)
return
username = parts[1].lstrip('@')
try:
amount = float(parts[2].replace(",", "."))
except ValueError:
await message.reply("Сумма должна быть числом.", parse_mode=None)
return
if amount <= 0:
await message.reply("Сумма должна быть положительной.", parse_mode=None)
return
# Здесь нужно найти user_id по username
# Для простоты примера, пока покажем сообщение об ошибке
await message.reply("🔍 Поиск пользователя...\n(функция поиска пользователей будет добавлена)", parse_mode=None)
async def handle_vanomasa_cmd(message: Message):
"""Полное обнуление (только для админов)"""
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
# Проверка на админа (простая реализация)
if message.from_user.id not in [123456789]: # Заменить на реальные ID админов
await message.reply("❌ Команда доступна только администраторам!", parse_mode=None)
return
success = await asyncio.to_thread(economy.vanomasa)
if success:
await message.reply("💥 **ВАНОМАСА!** 💥\n\nВсе балансы обнулены!", parse_mode=None)
else:
await message.reply("❌ Ваномаса еще не доступна. Подождите.", parse_mode=None)
async def handle_central_bank_cmd(message: Message):
"""Статистика Центрального Банка"""
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
cb_stats = await asyncio.to_thread(economy.get_central_bank_stats)
text = f"""🏛️ **Центральный Банк ZOV OS** (децентрализованный)
💰 **Капитал ЦБ:** {cb_stats['capital']:.2f} см
📊 **Статистика операций:**
Всего собрано налогов: {cb_stats['total_taxes_collected']:.2f} см
Регулирование инфляции: {cb_stats['total_inflation_adjustments']:.2f} см
Эмиссия денег: {cb_stats['total_emissions']:.2f} см
🤖 **Автоматическое регулирование:**
ЦБ работает по алгоритмам без вмешательства
Налоги автоматически поступают в ЦБ
Инфляция регулируется автоматически
Эмиссия при дефляции, скупка при инфляции
💡 **Команды ЦБ:**
/central_bank - детальная статистика
/cb_stats - краткая статистика
/cb_rules - правила работы ЦБ
"""
await message.answer(text, parse_mode=None)
async def handle_cb_stats_cmd(message: Message):
"""Статистика ЦБ для всех пользователей"""
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
cb_stats = await asyncio.to_thread(economy.get_central_bank_stats)
text = f"""🏛️ **Центральный Банк ZOV OS** (децентрализованный)
💰 **Капитал ЦБ:** {cb_stats['capital']:.2f} см
📊 **Операции ЦБ:**
Собрано налогов: {cb_stats['total_taxes_collected']:.2f} см
Антиинфляция: {cb_stats['total_inflation_adjustments']:.2f} см
Эмиссия: {cb_stats['total_emissions']:.2f} см
🔹 ЦБ работает автоматически по алгоритмам
🔹 Учредительный капитал: 1000 см
🔹 Все налоги поступают в ЦБ
🔹 Никакого ручного управления
"""
await message.answer(text, parse_mode=None)
async def handle_cb_rules_cmd(message: Message):
"""Правила работы Центрального Банка"""
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
text = f"""📖 **Правила Центрального Банка ZOV OS**
🤖 **Децентрализованная система:**
ЦБ работает полностью автоматически
Никакого ручного вмешательства
Все решения принимаются по алгоритмам
<EFBFBD> **Сбор налогов:**
13% налог автоматически поступает в ЦБ
Налоги собираются каждый час
Капитал ЦБ растет за счет налогов
📈 **Борьба с инфляцией:**
Если инфляция > 10%: ЦБ скупает деньги
Увеличивает налоги до 20% максимум
Скупает до 5% от массы денег
<EFBFBD> **Стимуляция экономики:**
Если инфляция < 2%: ЦБ проводит эмиссию
Уменьшает налоги до 10% минимум
Эмиссия до 5% от капитала ЦБ
**Баланс системы:**
ЦБ не может обанкротиться (учредительный капитал)
Автоматическая стабилизация экономики
Прозрачная статистика для всех
"""
await message.answer(text, parse_mode=None)
async def handle_gadanie_cmd(message: Message): async def handle_gadanie_cmd(message: Message):
parts = message.text.split(maxsplit=1) parts = message.text.split(maxsplit=1)
if len(parts) < 2 or not parts[1].strip(): if len(parts) < 2 or not parts[1].strip():
@ -1424,7 +1615,6 @@ def main():
token = os.getenv("BOT_TOKEN") token = os.getenv("BOT_TOKEN")
if not token: raise RuntimeError("BOT_TOKEN не задан!") if not token: raise RuntimeError("BOT_TOKEN не задан!")
init_penis_db()
init_polychaetsi_storage() init_polychaetsi_storage()
bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher() dp = Dispatcher()
@ -1456,7 +1646,20 @@ def main():
dp.message.register(handle_gen_mem, Command("gen_mem")) dp.message.register(handle_gen_mem, Command("gen_mem"))
dp.message.register(handle_uwu_cmd, Command("uwu")) dp.message.register(handle_uwu_cmd, Command("uwu"))
dp.message.register(handle_nude_cmd, Command("nude")) dp.message.register(handle_nude_cmd, Command("nude"))
dp.message.register(handle_photo_message, _is_supported_image_message) dp.message.register(handle_photo_message, _is_watch_image_message)
# Экономические команды
dp.message.register(handle_balance_cmd, Command("balance"))
dp.message.register(handle_deposit_cmd, Command("deposit"))
dp.message.register(handle_loans_cmd, Command("loans"))
dp.message.register(handle_loan_cmd, Command("loan"))
dp.message.register(handle_transfer_cmd, Command("transfer"))
dp.message.register(handle_vanomasa_cmd, Command("vanomasa"))
# Команды Центрального Банка (децентрализованные)
dp.message.register(handle_central_bank_cmd, Command("central_bank"))
dp.message.register(handle_cb_stats_cmd, Command("cb_stats"))
dp.message.register(handle_cb_rules_cmd, Command("cb_rules"))
dp.message.register(handle_keywords, F.text) dp.message.register(handle_keywords, F.text)
dp.message.register(handle_unmatched_message_debug) dp.message.register(handle_unmatched_message_debug)
@ -1477,6 +1680,7 @@ def main():
BotCommand(command="fetch", description="alias для /prices"), BotCommand(command="fetch", description="alias для /prices"),
BotCommand(command="zvetok", description="Цветянский бля"), BotCommand(command="zvetok", description="Цветянский бля"),
BotCommand(command="talk", description="Побазарить"), BotCommand(command="talk", description="Побазарить"),
BotCommand(command="watch", description="Коммент фото по подписи /watch"),
BotCommand(command="ebalnik", description="вкл/выкл автответов"), BotCommand(command="ebalnik", description="вкл/выкл автответов"),
BotCommand(command="penis_casino", description="казино на размер"), BotCommand(command="penis_casino", description="казино на размер"),
BotCommand(command="gadanie", description="гадание на фене"), BotCommand(command="gadanie", description="гадание на фене"),
@ -1488,6 +1692,13 @@ def main():
BotCommand(command="svodka", description="СВО: итоги"), BotCommand(command="svodka", description="СВО: итоги"),
BotCommand(command="uwu", description="Случайная картинка с e621"), BotCommand(command="uwu", description="Случайная картинка с e621"),
BotCommand(command="nude", description="Голые женщины из открытых источников"), BotCommand(command="nude", description="Голые женщины из открытых источников"),
BotCommand(command="balance", description="💰 баланс и статистика"),
BotCommand(command="deposit", description="💎 открыть вклад"),
BotCommand(command="loan", description="💵 взять кредит"),
BotCommand(command="transfer", description="💸 перевести деньги"),
BotCommand(command="cb_stats", description="🏛️ статистика ЦБ"),
BotCommand(command="central_bank", description="🏛️ детальная статистика ЦБ"),
BotCommand(command="cb_rules", description="📖 правила ЦБ"),
] ]
scopes = ( scopes = (
BotCommandScopeDefault(), BotCommandScopeDefault(),
@ -1499,6 +1710,9 @@ def main():
except Exception: except Exception:
logger.warning("Не удалось выставить команды, но бот продолжит работу.") logger.warning("Не удалось выставить команды, но бот продолжит работу.")
# Запуск фоновых задач экономики
asyncio.create_task(economy_background_tasks())
dp.startup.register(on_startup) dp.startup.register(on_startup)
async def start_settle(bot: Bot): async def start_settle(bot: Bot):
@ -1508,5 +1722,29 @@ def main():
logger.info("Бот запущен...") logger.info("Бот запущен...")
asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())) asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()))
async def economy_background_tasks():
"""Фоновые задачи для экономики"""
while True:
try:
moscow_time = datetime.now(ZoneInfo("Europe/Moscow"))
current_hour = moscow_time.hour
current_minute = moscow_time.minute
await asyncio.to_thread(economy.collect_daily_taxes)
await asyncio.to_thread(economy.regulate_inflation)
await asyncio.to_thread(economy.process_matured_deposits)
if current_hour == 0 and current_minute == 0:
users_paid = await asyncio.to_thread(economy.pay_daily_activity_rewards)
if users_paid > 0:
logger.info(f"Activity rewards paid to {users_paid} users at 00:00 MSK")
logger.info(f"Economy background tasks completed at {moscow_time.strftime('%H:%M:%S MSK')}")
except Exception as e:
logger.exception(f"Economy background task failed: {e}")
await asyncio.sleep(60)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View file

@ -5,4 +5,4 @@ aiohttp>=3.9.5
tzdata>=2024.1 tzdata>=2024.1
beautifulsoup4 beautifulsoup4
lxml lxml
pysqlite3-binary>=0.5.3 psycopg2-binary>=2.9.9

View file

@ -0,0 +1,369 @@
#!/usr/bin/env python3
"""
Migrate data from SQLite databases to PostgreSQL.
Usage (from repo root):
docker compose run --rm bot python3 scripts/migrate_sqlite_to_pg.py
Or locally (PostgreSQL must be reachable):
DATABASE_URL=postgresql://bot:botpass@localhost:5432/botdb python3 scripts/migrate_sqlite_to_pg.py
SQLite sources expected in ./db/:
economy.sqlite3 user_balances, deposits, loans, transactions, taxes,
economy_settings, daily_activity, central_bank, penis_stats
bets.sqlite3 bets
chat_history.sqlite3 chat_history, chat_state, talk_history, talk_state
penis_stats.sqlite3 (legacy) length column user_balances.balance + penis_stats
"""
import os
import sqlite3
import sys
from pathlib import Path
import psycopg2
import psycopg2.extras
DB_DIR = Path(os.getenv("SQLITE_DIR", "/db"))
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
ECONOMY_DB = DB_DIR / "economy.sqlite3"
BETS_DB = DB_DIR / "bets.sqlite3"
CHAT_DB = DB_DIR / "chat_history.sqlite3"
PENIS_LEGACY_DB = DB_DIR / "penis_stats.sqlite3"
def open_sqlite(path: Path) -> sqlite3.Connection | None:
if not path.exists():
print(f" [skip] {path} not found")
return None
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
return conn
def migrate_economy(pg: psycopg2.extensions.connection) -> None:
src = open_sqlite(ECONOMY_DB)
if src is None:
return
cur = pg.cursor()
# --- user_balances ---
rows = src.execute("SELECT * FROM user_balances").fetchall()
print(f" user_balances: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset, created_at)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (user_id) DO UPDATE SET
balance = EXCLUDED.balance,
daily_income = EXCLUDED.daily_income,
last_daily_reset = EXCLUDED.last_daily_reset
""", (
r["user_id"],
r["balance"],
r["daily_income"],
r["last_daily_reset"] or None,
r["created_at"] or None,
))
# --- deposits ---
rows = src.execute("SELECT * FROM deposits").fetchall()
print(f" deposits: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO deposits (id, user_id, amount, interest_rate, created_at, matures_at, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO NOTHING
""", (
r["id"], r["user_id"], r["amount"], r["interest_rate"],
r["created_at"] or None, r["matures_at"] or None,
bool(r["is_active"]),
))
_reset_sequence(cur, "deposits", "id")
# --- loans ---
rows = src.execute("SELECT * FROM loans").fetchall()
print(f" loans: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO loans (id, user_id, amount, interest_rate, created_at, due_at, is_repaid)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO NOTHING
""", (
r["id"], r["user_id"], r["amount"], r["interest_rate"],
r["created_at"] or None, r["due_at"] or None,
bool(r["is_repaid"]),
))
_reset_sequence(cur, "loans", "id")
# --- transactions ---
rows = src.execute("SELECT * FROM transactions").fetchall()
print(f" transactions: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO transactions (id, from_user_id, to_user_id, amount, transaction_type, description, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO NOTHING
""", (
r["id"], r["from_user_id"], r["to_user_id"], r["amount"],
r["transaction_type"], r["description"], r["created_at"] or None,
))
_reset_sequence(cur, "transactions", "id")
# --- taxes ---
rows = src.execute("SELECT * FROM taxes").fetchall()
print(f" taxes: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO taxes (id, user_id, amount, tax_date, created_at)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (id) DO NOTHING
""", (
r["id"], r["user_id"], r["amount"],
r["tax_date"] or None, r["created_at"] or None,
))
_reset_sequence(cur, "taxes", "id")
# --- economy_settings ---
rows = src.execute("SELECT * FROM economy_settings").fetchall()
print(f" economy_settings: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO economy_settings (key, value, updated_at)
VALUES (%s, %s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (r["key"], r["value"], r["updated_at"] if "updated_at" in r.keys() else None))
# --- daily_activity ---
rows = src.execute("SELECT * FROM daily_activity").fetchall()
print(f" daily_activity: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO daily_activity (id, user_id, activity_date, message_count, last_activity_time, created_at)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (user_id, activity_date) DO UPDATE SET
message_count = EXCLUDED.message_count,
last_activity_time = EXCLUDED.last_activity_time
""", (
r["id"], r["user_id"],
r["activity_date"] or None, r["message_count"],
r["last_activity_time"] or None, r["created_at"] or None,
))
_reset_sequence(cur, "daily_activity", "id")
# --- central_bank ---
rows = src.execute("SELECT * FROM central_bank").fetchall()
print(f" central_bank: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO central_bank (id, capital, total_taxes_collected,
total_inflation_adjustments, total_emissions, last_updated)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET
capital = EXCLUDED.capital,
total_taxes_collected = EXCLUDED.total_taxes_collected,
total_inflation_adjustments = EXCLUDED.total_inflation_adjustments,
total_emissions = EXCLUDED.total_emissions,
last_updated = EXCLUDED.last_updated
""", (
r["id"], r["capital"],
r["total_taxes_collected"], r["total_inflation_adjustments"],
r["total_emissions"], r["last_updated"] or None,
))
# --- penis_stats (new schema: user_id, display_name, last_used_ts) ---
try:
rows = src.execute("SELECT * FROM penis_stats").fetchall()
print(f" penis_stats: {len(rows)} rows")
cols = rows[0].keys() if rows else []
for r in rows:
if "last_used_ts" in cols:
cur.execute("""
INSERT INTO penis_stats (user_id, display_name, last_used_ts)
VALUES (%s, %s, %s)
ON CONFLICT (user_id) DO UPDATE SET
display_name = EXCLUDED.display_name,
last_used_ts = EXCLUDED.last_used_ts
""", (r["user_id"], r["display_name"], r["last_used_ts"]))
else:
# old schema without last_used_ts
cur.execute("""
INSERT INTO penis_stats (user_id, display_name)
VALUES (%s, %s)
ON CONFLICT (user_id) DO UPDATE SET display_name = EXCLUDED.display_name
""", (r["user_id"], r.get("display_name", "")))
except sqlite3.OperationalError as e:
print(f" [warn] penis_stats in economy.sqlite3: {e}")
src.close()
pg.commit()
print(" economy.sqlite3 done")
def migrate_legacy_penis(pg: psycopg2.extensions.connection) -> None:
"""Old penis_stats.sqlite3 had a `length` column — map it to user_balances.balance."""
src = open_sqlite(PENIS_LEGACY_DB)
if src is None:
return
cur = pg.cursor()
try:
rows = src.execute("SELECT user_id, length, display_name FROM penis_stats").fetchall()
except sqlite3.OperationalError:
rows = src.execute("SELECT * FROM penis_stats").fetchall()
print(f" penis_stats.sqlite3 (legacy): {len(rows)} rows")
for r in rows:
uid = r["user_id"]
length = r["length"] if "length" in r.keys() else 0.0
name = r["display_name"] if "display_name" in r.keys() else ""
last_ts = r["last_used_ts"] if "last_used_ts" in r.keys() else None
# upsert into user_balances; don't overwrite if already migrated from economy.sqlite3
cur.execute("""
INSERT INTO user_balances (user_id, balance)
VALUES (%s, %s)
ON CONFLICT (user_id) DO NOTHING
""", (uid, float(length)))
cur.execute("""
INSERT INTO penis_stats (user_id, display_name, last_used_ts)
VALUES (%s, %s, %s)
ON CONFLICT (user_id) DO UPDATE SET
display_name = EXCLUDED.display_name,
last_used_ts = COALESCE(EXCLUDED.last_used_ts, penis_stats.last_used_ts)
""", (uid, name, last_ts))
src.close()
pg.commit()
print(" penis_stats.sqlite3 done")
def migrate_bets(pg: psycopg2.extensions.connection) -> None:
src = open_sqlite(BETS_DB)
if src is None:
return
cur = pg.cursor()
rows = src.execute("SELECT * FROM bets").fetchall()
print(f" bets: {len(rows)} rows")
cols = rows[0].keys() if rows else []
for r in rows:
cur.execute("""
INSERT INTO bets (id, user_id, match_id, sport, home_team, away_team,
chosen_team, amount, odds, status, payout, created_ts, resolved_ts)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO NOTHING
""", (
r["id"], r["user_id"], r["match_id"], r["sport"],
r["home_team"], r["away_team"], r["chosen_team"],
r["amount"], r["odds"], r["status"],
r["payout"] if "payout" in cols else 0,
r["created_ts"] if "created_ts" in cols else None,
r["resolved_ts"] if "resolved_ts" in cols else None,
))
_reset_sequence(cur, "bets", "id")
src.close()
pg.commit()
print(" bets.sqlite3 done")
def migrate_chat(pg: psycopg2.extensions.connection) -> None:
src = open_sqlite(CHAT_DB)
if src is None:
return
cur = pg.cursor()
# --- chat_history ---
rows = src.execute("SELECT * FROM chat_history").fetchall()
print(f" chat_history: {len(rows)} rows")
cols = rows[0].keys() if rows else []
for r in rows:
cur.execute("""
INSERT INTO chat_history (id, chat_id, role, name, text, created_at)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO NOTHING
""", (
r["id"], r["chat_id"], r["role"], r["name"], r["text"],
r["created_at"] if "created_at" in cols else 0,
))
_reset_sequence(cur, "chat_history", "id")
# --- chat_state ---
rows = src.execute("SELECT * FROM chat_state").fetchall()
print(f" chat_state: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO chat_state (chat_id, key, value)
VALUES (%s, %s, %s)
ON CONFLICT (chat_id, key) DO UPDATE SET value = EXCLUDED.value
""", (r["chat_id"], r["key"], r["value"]))
# --- talk_history ---
rows = src.execute("SELECT * FROM talk_history").fetchall()
print(f" talk_history: {len(rows)} rows")
cols = rows[0].keys() if rows else []
for r in rows:
cur.execute("""
INSERT INTO talk_history (id, chat_id, user_id, role, name, text, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO NOTHING
""", (
r["id"], r["chat_id"], r["user_id"], r["role"], r["name"], r["text"],
r["created_at"] if "created_at" in cols else 0,
))
_reset_sequence(cur, "talk_history", "id")
# --- talk_state ---
rows = src.execute("SELECT * FROM talk_state").fetchall()
print(f" talk_state: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO talk_state (chat_id, user_id, key, value)
VALUES (%s, %s, %s, %s)
ON CONFLICT (chat_id, user_id, key) DO UPDATE SET value = EXCLUDED.value
""", (r["chat_id"], r["user_id"], r["key"], r["value"]))
src.close()
pg.commit()
print(" chat_history.sqlite3 done")
def _reset_sequence(cur, table: str, col: str) -> None:
"""Reset SERIAL sequence to max(col) so future inserts don't collide."""
cur.execute(f"SELECT setval(pg_get_serial_sequence('{table}', '{col}'), COALESCE(MAX({col}), 1)) FROM {table}")
def main() -> None:
print(f"Connecting to PostgreSQL: {DATABASE_URL}")
try:
pg = psycopg2.connect(DATABASE_URL)
except psycopg2.OperationalError as e:
print(f"ERROR: cannot connect to PostgreSQL: {e}", file=sys.stderr)
sys.exit(1)
pg.autocommit = False
print("\n=== economy.sqlite3 ===")
migrate_economy(pg)
print("\n=== penis_stats.sqlite3 (legacy) ===")
migrate_legacy_penis(pg)
print("\n=== bets.sqlite3 ===")
migrate_bets(pg)
print("\n=== chat_history.sqlite3 ===")
migrate_chat(pg)
pg.close()
print("\nMigration complete.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,72 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import sys
import urllib.parse
import urllib.request
from pathlib import Path
def load_env_file(path: Path) -> None:
if not path.exists():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if not key:
continue
# сохраняем первое значение и игнорируем shell-комментарий после пробела
value = value.split(" #", 1)[0].strip()
if key not in os.environ:
os.environ[key] = value
def api_call(token: str, method: str, payload: dict) -> dict:
url = f"https://api.telegram.org/bot{token}/{method}"
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read().decode("utf-8"))
def main() -> int:
root = Path(__file__).resolve().parents[1]
load_env_file(root / ".env")
token = os.getenv("BOT_TOKEN", "").strip()
webapp_url = os.getenv("WEBAPP_URL", "").strip()
if not token:
print("BOT_TOKEN пустой в .env", file=sys.stderr)
return 1
if not webapp_url:
print("WEBAPP_URL пустой в .env", file=sys.stderr)
return 1
parsed = urllib.parse.urlparse(webapp_url)
if parsed.scheme != "https":
print("WEBAPP_URL должен быть https", file=sys.stderr)
return 1
payload = {
"menu_button": {
"type": "web_app",
"text": "Mini App",
"web_app": {"url": webapp_url},
}
}
result = api_call(token, "setChatMenuButton", payload)
if not result.get("ok"):
print(f"setChatMenuButton failed: {result}", file=sys.stderr)
return 1
print(f"Menu button updated: {webapp_url}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/docker-compose.webapp-test.yml"
ENV_FILE="${ROOT_DIR}/.env"
PROJECT_NAME="fenya-webapp-test"
if [[ ! -f "${COMPOSE_FILE}" ]]; then
echo "Не найден ${COMPOSE_FILE}" >&2
exit 1
fi
if [[ ! -f "${ENV_FILE}" ]]; then
echo "Не найден ${ENV_FILE}" >&2
exit 1
fi
if ! grep -qE '^NGROK_AUTHTOKEN=' "${ENV_FILE}"; then
fallback_token="$(python - <<'PY'
import re
from pathlib import Path
p = Path("webapp/ngrok_tunnel.py")
if not p.exists():
print("")
raise SystemExit
txt = p.read_text(encoding="utf-8", errors="ignore")
m = re.search(r'auth_token\s*=\s*"([^"]+)"', txt)
print(m.group(1) if m else "")
PY
)"
if [[ -n "${fallback_token}" ]]; then
printf '\nNGROK_AUTHTOKEN=%s\n' "${fallback_token}" >> "${ENV_FILE}"
echo "Добавил NGROK_AUTHTOKEN в .env из webapp/ngrok_tunnel.py"
else
echo "NGROK_AUTHTOKEN не найден. Добавь в .env строку NGROK_AUTHTOKEN=..." >&2
exit 1
fi
fi
cd "${ROOT_DIR}"
docker compose -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" up -d --build
echo "Жду инициализацию ngrok..."
public_url=""
for _ in $(seq 1 45); do
public_url="$(python - <<'PY'
import json
import urllib.request
try:
with urllib.request.urlopen("http://127.0.0.1:4041/api/tunnels", timeout=2) as resp:
data = json.load(resp)
tunnels = data.get("tunnels", [])
for t in tunnels:
url = str(t.get("public_url", ""))
if url.startswith("https://"):
print(url)
break
except Exception:
pass
PY
)"
if [[ -n "${public_url}" ]]; then
break
fi
sleep 1
done
if [[ -z "${public_url}" ]]; then
echo "Не удалось получить ngrok URL. Проверь логи: docker logs fenyabot-ngrok-test" >&2
exit 1
fi
python - <<'PY' "${ENV_FILE}" "${public_url}"
import re
import sys
from pathlib import Path
env_path = Path(sys.argv[1])
url = sys.argv[2]
text = env_path.read_text(encoding="utf-8")
if re.search(r"^WEBAPP_URL=.*$", text, flags=re.MULTILINE):
text = re.sub(r"^WEBAPP_URL=.*$", f"WEBAPP_URL={url}", text, flags=re.MULTILINE)
else:
text = text.rstrip() + f"\nWEBAPP_URL={url}\n"
env_path.write_text(text, encoding="utf-8")
PY
echo
echo "Готово."
echo "NGROK URL: ${public_url}"
echo "WEBAPP_URL обновлён в .env"
echo
echo "Для остановки тест-контура:"
echo " docker compose -p ${PROJECT_NAME} -f ${COMPOSE_FILE} down"

View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/docker-compose.webapp-test.yml"
PROJECT_NAME="fenya-webapp-test"
cd "${ROOT_DIR}"
docker compose -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" down

View file

@ -10,6 +10,8 @@ import logging
import os import os
import hashlib import hashlib
from urllib.parse import quote from urllib.parse import quote
from urllib.parse import urljoin
from urllib.parse import urlparse
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Annotated from typing import Annotated
@ -17,7 +19,7 @@ from typing import Annotated
import aiohttp import aiohttp
from fastapi import FastAPI, Depends, Header, HTTPException, Request from fastapi import FastAPI, Depends, Header, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response from fastapi.responses import Response, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
@ -36,6 +38,7 @@ from games.betting import (
) )
from webapp.auth import validate_init_data from webapp.auth import validate_init_data
from games.uwu import fetch_uwu_post, fetch_furtok_feed from games.uwu import fetch_uwu_post, fetch_furtok_feed
from webapp.shorties import fetch_shorties_feed
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -142,15 +145,16 @@ async def get_profile(user: dict = Depends(get_current_user)) -> ProfileResponse
user_id = user["user_id"] user_id = user["user_id"]
length = await asyncio.to_thread(get_user_length, user_id) length = await asyncio.to_thread(get_user_length, user_id)
# Считаем активные ставки
import sqlite3
active_bets = 0 active_bets = 0
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: from db import get_conn
row = conn.execute( with get_conn() as conn:
"SELECT COUNT(*) FROM bets WHERE user_id = ? AND status = 'pending'", cur = conn.cursor()
cur.execute(
"SELECT COUNT(*) FROM bets WHERE user_id = %s AND status = 'pending'",
(user_id,), (user_id,),
).fetchone() )
row = cur.fetchone()
active_bets = row[0] if row else 0 active_bets = row[0] if row else 0
except Exception: except Exception:
pass pass
@ -405,6 +409,23 @@ async def get_furtok_feed_api(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/furtok/shorties")
async def get_shorties_feed_api(
page: int = 1,
count: int = 8,
user: dict = Depends(get_current_user),
):
"""Случайная shorties-лента с Pornhub."""
try:
# page используем как "seed-сдвиг" для подгрузки следующих рандомных наборов.
random_pages = 2 + min(max(page, 1), 6)
feed = await fetch_shorties_feed(count=count, random_pages=random_pages)
return {"feed": feed}
except Exception as e:
logger.exception("Shorties API failed")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/proxy-image") @app.get("/api/proxy-image")
async def proxy_image(url: str): async def proxy_image(url: str):
"""Проксирует картинку с e621 чтобы обойти hotlink protection.""" """Проксирует картинку с e621 чтобы обойти hotlink protection."""
@ -433,6 +454,119 @@ async def proxy_image(url: str):
raise HTTPException(status_code=502, detail="Failed to fetch image") raise HTTPException(status_code=502, detail="Failed to fetch image")
def _is_allowed_shorties_media_host(hostname: str) -> bool:
host = (hostname or "").lower()
if not host:
return False
if host in {"www.pornhub.com", "pornhub.com", "phncdn.com"}:
return True
if host.endswith(".phncdn.com"):
return True
return False
@app.get("/api/proxy-shorties-media")
async def proxy_shorties_media(url: str, request: Request):
"""Проксирует видео shorties, чтобы избежать ограничений hotlink/CORS."""
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not _is_allowed_shorties_media_host(parsed.hostname or ""):
raise HTTPException(status_code=403, detail="Forbidden host")
headers = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Referer": "https://www.pornhub.com/shorties",
"Origin": "https://www.pornhub.com",
}
range_header = request.headers.get("range")
if range_header:
headers["Range"] = range_header
timeout = aiohttp.ClientTimeout(total=120)
session = aiohttp.ClientSession(timeout=timeout)
try:
resp = await session.get(url, headers=headers, allow_redirects=True)
except aiohttp.ClientError:
await session.close()
raise HTTPException(status_code=502, detail="Failed to fetch media")
if resp.status not in {200, 206}:
await resp.release()
await session.close()
raise HTTPException(status_code=resp.status, detail="Upstream error")
content_type = resp.headers.get("Content-Type", "application/octet-stream")
if ".m3u8" in parsed.path.lower() or "mpegurl" in content_type.lower():
try:
body = await resp.text()
finally:
await resp.release()
await session.close()
def _proxy_media_url(target_url: str) -> str:
return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}"
rewritten_lines: list[str] = []
for raw_line in body.splitlines():
stripped = raw_line.strip()
if not stripped:
rewritten_lines.append(raw_line)
continue
if stripped.startswith("#EXT-X-KEY") and 'URI="' in raw_line:
prefix, rest = raw_line.split('URI="', 1)
original_uri, suffix = rest.split('"', 1)
absolute_uri = urljoin(url, original_uri)
rewritten_lines.append(f'{prefix}URI="{_proxy_media_url(absolute_uri)}"{suffix}')
continue
if stripped.startswith("#"):
rewritten_lines.append(raw_line)
continue
absolute_uri = urljoin(url, stripped)
rewritten_lines.append(_proxy_media_url(absolute_uri))
playlist_body = "\n".join(rewritten_lines)
return Response(
content=playlist_body,
media_type=content_type,
headers={
"Cache-Control": "public, max-age=120",
"Access-Control-Allow-Origin": "*",
},
)
passthrough_headers = {
"Cache-Control": "public, max-age=600",
"Accept-Ranges": resp.headers.get("Accept-Ranges", "bytes"),
"Access-Control-Allow-Origin": "*",
}
if resp.headers.get("Content-Length"):
passthrough_headers["Content-Length"] = resp.headers["Content-Length"]
if resp.headers.get("Content-Range"):
passthrough_headers["Content-Range"] = resp.headers["Content-Range"]
async def _stream():
try:
async for chunk in resp.content.iter_chunked(64 * 1024):
yield chunk
finally:
await resp.release()
await session.close()
return StreamingResponse(
_stream(),
media_type=content_type,
status_code=resp.status,
headers=passthrough_headers,
)
# --- ИИ Чат --- # --- ИИ Чат ---
@app.post("/api/talk") @app.post("/api/talk")

View file

@ -912,7 +912,8 @@ body::before {
} }
.furtok-card video, .furtok-card video,
.furtok-card img { .furtok-card img,
.furtok-card iframe {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@ -920,6 +921,7 @@ body::before {
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
display: block; display: block;
border: 0;
} }
.furtok-overlay { .furtok-overlay {
@ -936,6 +938,36 @@ body::before {
pointer-events: none; pointer-events: none;
} }
.furtok-side-actions {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
z-index: 4;
display: flex;
flex-direction: column;
gap: 8px;
}
.furtok-share-btn {
border: 1px solid rgba(255,255,255,0.35);
background: rgba(0,0,0,0.45);
color: #fff;
border-radius: 999px;
padding: 8px 12px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
transition: transform 0.15s ease, background 0.2s ease;
}
.furtok-share-btn:active {
transform: scale(0.96);
background: rgba(0,0,0,0.65);
}
.furtok-caption { .furtok-caption {
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
@ -989,6 +1021,37 @@ body::before {
gap: 10px; gap: 10px;
} }
.furtok-mode-switch {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: 999px;
background: rgba(255,255,255,0.14);
border: 1px solid rgba(255,255,255,0.2);
}
.furtok-mode-btn {
border: none;
background: transparent;
color: rgba(255,255,255,0.8);
font-size: 11px;
font-weight: 700;
padding: 5px 9px;
border-radius: 999px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.furtok-mode-btn.active {
background: rgba(96, 165, 250, 0.3);
color: #fff;
}
.furtok-mode-btn:active {
transform: scale(0.96);
}
.furtok-gear-btn { .furtok-gear-btn {
background: rgba(255,255,255,0.15); background: rgba(255,255,255,0.15);
border: none; border: none;

View file

@ -10,6 +10,7 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js"></script>
</head> </head>
<body> <body>
<div id="app"> <div id="app">

View file

@ -42,5 +42,7 @@ export const API = {
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`), getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`), getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`),
getFurtokFeed: (safe = true, page = 1, tags = '') => api(`/api/furtok?safe=${safe}&page=${page}${tags ? '&tags=' + encodeURIComponent(tags) : ''}`), getFurtokFeed: (safe = true, page = 1, tags = '') => api(`/api/furtok?safe=${safe}&page=${page}${tags ? '&tags=' + encodeURIComponent(tags) : ''}`),
getShortiesFeed: (page = 1, count = 8) => api(`/api/furtok/shorties?page=${page}&count=${count}`),
proxyShortiesMediaUrl: (url) => `${API_BASE}/api/proxy-shorties-media?url=${encodeURIComponent(url || '')}`,
talk: (text) => api('/api/talk', { method: 'POST', body: { text } }), talk: (text) => api('/api/talk', { method: 'POST', body: { text } }),
}; };

View file

@ -33,6 +33,12 @@ const navBtns = document.querySelectorAll('.nav-btn');
// ── Router ── // ── Router ──
function navigate(page, data = null) { function navigate(page, data = null) {
document.querySelectorAll('.furtok-card video').forEach((video) => {
if (video && video._hlsInstance) {
try { video._hlsInstance.destroy(); } catch {}
video._hlsInstance = null;
}
});
currentPage = page; currentPage = page;
haptic(); haptic();
// Убираем оверлеи при смене страницы // Убираем оверлеи при смене страницы
@ -66,6 +72,82 @@ function $(html) {
return t.content.firstChild; return t.content.firstChild;
} }
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function initShortiesVideoPlayback(video) {
if (!video) return;
const hlsSrc = video.dataset.hlsSrc || '';
if (!hlsSrc) return;
const canPlayNativeHls = video.canPlayType('application/vnd.apple.mpegurl');
if (canPlayNativeHls) {
video.src = hlsSrc;
return;
}
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls({
maxBufferLength: 30,
backBufferLength: 30,
enableWorker: true,
});
hls.loadSource(hlsSrc);
hls.attachMedia(video);
video._hlsInstance = hls;
}
}
function getShortiesShareUrl(post) {
const source = String(post?.source || '').trim();
if (source) return source;
return String(post?.url || '').trim();
}
function saveShortiesLinkLocally(url) {
if (!url) return;
const key = 'shorties_saved_links';
let links = [];
try {
const parsed = JSON.parse(localStorage.getItem(key) || '[]');
if (Array.isArray(parsed)) links = parsed.filter((item) => typeof item === 'string' && item.trim());
} catch {}
if (!links.includes(url)) {
links.unshift(url);
localStorage.setItem(key, JSON.stringify(links.slice(0, 200)));
}
}
async function copyToClipboard(text) {
if (!text) return false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {}
try {
const input = document.createElement('textarea');
input.value = text;
input.setAttribute('readonly', '');
input.style.position = 'absolute';
input.style.left = '-9999px';
document.body.appendChild(input);
input.select();
const ok = document.execCommand('copy');
document.body.removeChild(input);
return !!ok;
} catch {
return false;
}
}
function showResult(icon, title, desc, btnText = 'OK') { function showResult(icon, title, desc, btnText = 'OK') {
return new Promise(resolve => { return new Promise(resolve => {
const overlay = $(` const overlay = $(`
@ -507,6 +589,7 @@ let furtokWrapper = null;
let furtokCurrentIndex = 0; let furtokCurrentIndex = 0;
let furtokCards = []; let furtokCards = [];
let furtokCustomTags = ''; let furtokCustomTags = '';
let furtokMode = 'furtok'; // "furtok" | "shorties"
// Подписи из uwu.py — рандомно появляются на карточках // Подписи из uwu.py — рандомно появляются на карточках
const FURTOK_CAPTIONS = [ const FURTOK_CAPTIONS = [
@ -531,6 +614,7 @@ function randomCaption() {
} }
function _furtokGetTags() { function _furtokGetTags() {
if (furtokMode === 'shorties') return '';
// Если кастомные теги заданы — используем их (safe управляется юзером через теги) // Если кастомные теги заданы — используем их (safe управляется юзером через теги)
if (furtokCustomTags.trim()) return furtokCustomTags.trim(); if (furtokCustomTags.trim()) return furtokCustomTags.trim();
// Иначе стандартный запрос с safe-переключателем // Иначе стандартный запрос с safe-переключателем
@ -546,6 +630,29 @@ function _furtokReload(feed) {
loadFurtokPage(feed); loadFurtokPage(feed);
} }
function _updateFurtokUiMode() {
const safeWrap = document.getElementById('furtok-safe-wrap');
const gearBtn = document.getElementById('furtok-gear');
const tagsPanel = document.getElementById('furtok-tags-panel');
const title = document.getElementById('furtok-title');
if (title) {
title.textContent = furtokMode === 'shorties' ? '🔥 Shorties' : '🐺 FurTok';
}
if (!safeWrap || !gearBtn || !tagsPanel) return;
if (furtokMode === 'shorties') {
safeWrap.style.display = 'none';
gearBtn.style.display = 'none';
tagsPanel.style.display = 'none';
return;
}
gearBtn.style.display = '';
safeWrap.style.display = furtokCustomTags ? 'none' : '';
}
function renderFurtok() { function renderFurtok() {
container.innerHTML = ''; container.innerHTML = '';
document.querySelector('.furtok-wrapper')?.remove(); document.querySelector('.furtok-wrapper')?.remove();
@ -559,8 +666,12 @@ function renderFurtok() {
furtokWrapper.className = 'furtok-wrapper'; furtokWrapper.className = 'furtok-wrapper';
furtokWrapper.innerHTML = ` furtokWrapper.innerHTML = `
<div class="furtok-header"> <div class="furtok-header">
<div class="furtok-title">🐺 FurTok</div> <div class="furtok-title" id="furtok-title">${furtokMode === 'shorties' ? '🔥 Shorties' : '🐺 FurTok'}</div>
<div class="furtok-header-right"> <div class="furtok-header-right">
<div class="furtok-mode-switch" id="furtok-mode-switch">
<button class="furtok-mode-btn ${furtokMode === 'furtok' ? 'active' : ''}" data-mode="furtok">FurTok</button>
<button class="furtok-mode-btn ${furtokMode === 'shorties' ? 'active' : ''}" data-mode="shorties">Shorties</button>
</div>
<label class="furtok-toggle" id="furtok-safe-wrap" ${furtokCustomTags.trim() ? 'style="display:none"' : ''}> <label class="furtok-toggle" id="furtok-safe-wrap" ${furtokCustomTags.trim() ? 'style="display:none"' : ''}>
<span>Safe</span> <span>Safe</span>
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}> <input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}>
@ -588,6 +699,23 @@ function renderFurtok() {
const tagsPanel = document.getElementById('furtok-tags-panel'); const tagsPanel = document.getElementById('furtok-tags-panel');
const tagsInput = document.getElementById('furtok-tags-input'); const tagsInput = document.getElementById('furtok-tags-input');
const tagsApply = document.getElementById('furtok-tags-apply'); const tagsApply = document.getElementById('furtok-tags-apply');
const modeSwitch = document.getElementById('furtok-mode-switch');
_updateFurtokUiMode();
modeSwitch?.addEventListener('click', (e) => {
const btn = e.target.closest('.furtok-mode-btn');
if (!btn) return;
const nextMode = btn.dataset.mode;
if (!nextMode || nextMode === furtokMode) return;
haptic();
furtokMode = nextMode;
modeSwitch.querySelectorAll('.furtok-mode-btn').forEach((item) => {
item.classList.toggle('active', item.dataset.mode === furtokMode);
});
_updateFurtokUiMode();
_furtokReload(feed);
});
// Safe toggle // Safe toggle
safeToggle.addEventListener('change', () => { safeToggle.addEventListener('change', () => {
@ -608,7 +736,7 @@ function renderFurtok() {
haptic(); haptic();
furtokCustomTags = tagsInput.value.trim(); furtokCustomTags = tagsInput.value.trim();
// Если кастомные теги — прячем Safe (юзер сам контролирует rating) // Если кастомные теги — прячем Safe (юзер сам контролирует rating)
safeWrap.style.display = furtokCustomTags ? 'none' : ''; _updateFurtokUiMode();
tagsPanel.style.display = 'none'; tagsPanel.style.display = 'none';
_furtokReload(feed); _furtokReload(feed);
}); });
@ -727,7 +855,9 @@ async function loadFurtokPage(feedEl) {
feedEl.appendChild(loader); feedEl.appendChild(loader);
try { try {
const res = await API.getFurtokFeed(furtokSafe, furtokPage, furtokCustomTags); const res = furtokMode === 'shorties'
? await API.getShortiesFeed(furtokPage, 8)
: await API.getFurtokFeed(furtokSafe, furtokPage, furtokCustomTags);
loader.remove(); loader.remove();
if (!res.feed || res.feed.length === 0) { if (!res.feed || res.feed.length === 0) {
@ -741,23 +871,44 @@ async function loadFurtokPage(feedEl) {
res.feed.forEach(post => { res.feed.forEach(post => {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'furtok-card'; card.className = 'furtok-card';
const isShorties = furtokMode === 'shorties';
const shareUrl = getShortiesShareUrl(post);
let mediaHtml = ''; let mediaHtml = '';
if (post.type === 'image' || post.ext === 'gif') { if (post.type === 'image' || post.ext === 'gif') {
mediaHtml = `<img src="${post.url}" loading="lazy">`; mediaHtml = `<img src="${post.url}" loading="lazy" alt="">`;
} else { } else {
mediaHtml = `<video src="${post.url}" loop playsinline preload="metadata" muted></video>`; const poster = post.sample ? ` poster="${post.sample}"` : '';
const directMp4 = post.mp4_url || post.url || '';
const proxyMp4 = isShorties ? API.proxyShortiesMediaUrl(directMp4) : post.url;
const proxyHls = isShorties && post.hls_url ? API.proxyShortiesMediaUrl(post.hls_url) : '';
const useHls = isShorties && !directMp4 && Boolean(post.hls_url);
const videoSrc = useHls ? '' : proxyMp4;
const hlsSrc = isShorties ? escapeHtml(proxyHls) : '';
mediaHtml = `<video src="${videoSrc}" data-direct-src="${escapeHtml(directMp4)}" data-proxy-src="${escapeHtml(proxyMp4)}" data-hls-src="${hlsSrc}"${poster} loop autoplay playsinline preload="metadata" muted></video>`;
} }
const caption = randomCaption(); const title = post.title ? escapeHtml(post.title) : '';
const caption = title || randomCaption();
const scoreLabel = furtokMode === 'shorties'
? `👁️ ${escapeHtml(post.views || '—')}`
: `${escapeHtml(post.score ?? 0)}`;
const favLabel = furtokMode === 'shorties'
? `⏱️ ${escapeHtml(post.duration || '—')}`
: `❤️ ${escapeHtml(post.fav_count ?? 0)}`;
card.innerHTML = ` card.innerHTML = `
${mediaHtml} ${mediaHtml}
${isShorties ? `
<div class="furtok-side-actions">
<button class="furtok-share-btn" type="button" aria-label="Share">🔗 Share</button>
</div>
` : ''}
<div class="furtok-overlay"> <div class="furtok-overlay">
<div class="furtok-caption">${caption}</div> <div class="furtok-caption">${caption}</div>
<div class="furtok-stats"> <div class="furtok-stats">
<span> ${post.score}</span> <span>${scoreLabel}</span>
<span> ${post.fav_count}</span> <span>${favLabel}</span>
</div> </div>
</div> </div>
`; `;
@ -765,6 +916,15 @@ async function loadFurtokPage(feedEl) {
// Тап по видео = unmute + play/pause // Тап по видео = unmute + play/pause
const video = card.querySelector('video'); const video = card.querySelector('video');
if (video) { if (video) {
if (isShorties) {
initShortiesVideoPlayback(video);
}
video.addEventListener('error', () => {
if (isShorties && video.dataset.proxySrc && video.src !== video.dataset.proxySrc) {
video.src = video.dataset.proxySrc;
video.load();
}
});
card.addEventListener('click', () => { card.addEventListener('click', () => {
video.muted = false; video.muted = false;
if (video.paused) video.play(); if (video.paused) video.play();
@ -772,6 +932,23 @@ async function loadFurtokPage(feedEl) {
}); });
} }
const shareBtn = card.querySelector('.furtok-share-btn');
if (shareBtn) {
shareBtn.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
if (!shareUrl) return;
const copied = await copyToClipboard(shareUrl);
saveShortiesLinkLocally(shareUrl);
if (copied) haptic('success');
else haptic('impact');
shareBtn.textContent = copied ? '✅ Saved' : '💾 Saved';
setTimeout(() => {
shareBtn.textContent = '🔗 Share';
}, 1200);
});
}
feedEl.appendChild(card); feedEl.appendChild(card);
furtokCards.push(card); furtokCards.push(card);
}); });
@ -779,7 +956,10 @@ async function loadFurtokPage(feedEl) {
// Автоплей первого видео при первой загрузке // Автоплей первого видео при первой загрузке
if (furtokCurrentIndex === 0 && furtokCards.length > 0) { if (furtokCurrentIndex === 0 && furtokCards.length > 0) {
const firstVideo = furtokCards[0].querySelector('video'); const firstVideo = furtokCards[0].querySelector('video');
if (firstVideo) firstVideo.play().catch(() => {}); if (firstVideo) {
firstVideo.muted = true;
firstVideo.play().catch(() => {});
}
} }
haptic('success'); haptic('success');

View file

@ -40,6 +40,9 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Telegram-Init-Data $http_x_telegram_init_data; proxy_set_header X-Telegram-Init-Data $http_x_telegram_init_data;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 300s;
} }
location / { location / {

View file

@ -1,2 +1,3 @@
fastapi>=0.111.0 fastapi>=0.111.0
uvicorn[standard]>=0.29.0 uvicorn[standard]>=0.29.0
aiohttp>=3.9.0

479
webapp/shorties.py Normal file
View file

@ -0,0 +1,479 @@
from __future__ import annotations
import asyncio
import html
import json
import logging
import random
import re
from dataclasses import dataclass
from urllib.parse import urljoin
import aiohttp
logger = logging.getLogger(__name__)
PH_BASE_URL = "https://rt.pornhub.com"
SHORTIES_LIST_URL = f"{PH_BASE_URL}/shorties"
REQUEST_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
"Referer": PH_BASE_URL,
}
_A_HREF_RE = re.compile(
r'<a[^>]+href="(?P<href>/view_video\.php\?viewkey=[^"]+)"(?P<attrs>[^>]*)>(?P<body>.*?)</a>',
re.IGNORECASE | re.DOTALL,
)
_IMG_URL_RE = re.compile(
r'(?:data-mediumthumb|data-path|data-thumb_url|src)="(?P<url>https?://[^"]+)"',
re.IGNORECASE,
)
_TITLE_ATTR_RE = re.compile(r'title="(?P<title>[^"]+)"', re.IGNORECASE)
_SCRIPT_LD_JSON_RE = re.compile(
r'<script[^>]+type="application/ld\+json"[^>]*>(?P<json>.*?)</script>',
re.IGNORECASE | re.DOTALL,
)
_MEDIA_DEFS_RE = re.compile(r'"mediaDefinitions"\s*:\s*(\[[^\]]+\])', re.IGNORECASE | re.DOTALL)
_VIDEO_URL_RE = re.compile(r'"videoUrl"\s*:\s*"(?P<url>https?:\\?/\\?/[^"]+\.mp4[^"]*)"', re.IGNORECASE)
_DURATION_RE = re.compile(r'"video_duration"\s*:\s*"?(?P<duration>\d+)"?', re.IGNORECASE)
_VIEWS_RE = re.compile(r'"video_views"\s*:\s*"?(?P<views>[0-9,\.]+)"?', re.IGNORECASE)
_JSON_SHORTIES_MARKER = "JSON_SHORTIES = insertAfterNthPosition("
_EMBED_SRC_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
_CYRILLIC_RE = re.compile(r"[А-Яа-яЁё]")
_RUSSIAN_MARKERS = {
"russian",
"russia",
"russkiy",
"russkaya",
"russkoe",
"русский",
"русская",
"русское",
"россия",
}
@dataclass
class ShortiesCandidate:
page_url: str
title: str
thumb_url: str
duration: str = ""
views: str = ""
def _clean_html_text(value: str) -> str:
no_tags = re.sub(r"<[^>]+>", " ", value)
return " ".join(html.unescape(no_tags).split())
def _absolute_url(value: str) -> str:
return urljoin(PH_BASE_URL, value)
def _extract_ldjson_candidates(page_html: str) -> list[ShortiesCandidate]:
out: list[ShortiesCandidate] = []
for match in _SCRIPT_LD_JSON_RE.finditer(page_html):
raw_json = html.unescape(match.group("json").strip())
if not raw_json:
continue
try:
parsed = json.loads(raw_json)
except json.JSONDecodeError:
continue
nodes: list[object]
if isinstance(parsed, list):
nodes = parsed
else:
nodes = [parsed]
for node in nodes:
if not isinstance(node, dict):
continue
if str(node.get("@type", "")).lower() != "videoobject":
continue
page_url = str(node.get("url") or "").strip()
thumb = str(node.get("thumbnailUrl") or "").strip()
title = str(node.get("name") or "").strip() or "Shorties"
if not page_url:
continue
out.append(
ShortiesCandidate(
page_url=_absolute_url(page_url),
title=title,
thumb_url=_absolute_url(thumb) if thumb else "",
)
)
return out
def _extract_anchor_candidates(page_html: str) -> list[ShortiesCandidate]:
out: list[ShortiesCandidate] = []
for match in _A_HREF_RE.finditer(page_html):
href = match.group("href").strip()
attrs = match.group("attrs") or ""
body = match.group("body") or ""
page_url = _absolute_url(href)
title_match = _TITLE_ATTR_RE.search(attrs) or _TITLE_ATTR_RE.search(body)
title = _clean_html_text(title_match.group("title")) if title_match else _clean_html_text(body)
if not title:
title = "Shorties"
thumb_match = _IMG_URL_RE.search(attrs) or _IMG_URL_RE.search(body)
thumb_url = _absolute_url(thumb_match.group("url")) if thumb_match else ""
out.append(ShortiesCandidate(page_url=page_url, title=title, thumb_url=thumb_url))
return out
def _dedupe_candidates(items: list[ShortiesCandidate]) -> list[ShortiesCandidate]:
result: list[ShortiesCandidate] = []
seen: set[str] = set()
for item in items:
key = item.page_url.strip()
if not key or key in seen:
continue
seen.add(key)
result.append(item)
return result
async def _fetch_text(session: aiohttp.ClientSession, url: str) -> str:
async with session.get(url, headers=REQUEST_HEADERS) as resp:
if resp.status != 200:
logger.warning("Shorties request failed: %s status=%s", url, resp.status)
return ""
return await resp.text()
def _extract_best_mp4(page_html: str) -> str:
media_match = _MEDIA_DEFS_RE.search(page_html)
if media_match:
raw = media_match.group(1)
try:
defs = json.loads(raw)
except json.JSONDecodeError:
defs = []
best_url = ""
best_quality = -1
for item in defs:
if not isinstance(item, dict):
continue
video_url = item.get("videoUrl")
if not isinstance(video_url, str) or ".mp4" not in video_url:
continue
quality_raw = str(item.get("quality") or "").strip("p ")
try:
quality = int(quality_raw)
except ValueError:
quality = 0
if quality >= best_quality:
best_quality = quality
best_url = video_url
if best_url:
return best_url.replace("\\/", "/")
for match in _VIDEO_URL_RE.finditer(page_html):
candidate = match.group("url").replace("\\/", "/")
if ".mp4" in candidate:
return candidate
return ""
def _extract_balanced_json_array(page_html: str, marker: str) -> str:
marker_idx = page_html.find(marker)
if marker_idx < 0:
return ""
arr_start = page_html.find("[", marker_idx)
if arr_start < 0:
return ""
depth = 0
in_string = False
escaped = False
for i in range(arr_start, len(page_html)):
ch = page_html[i]
if in_string:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
continue
if ch == "[":
depth += 1
continue
if ch == "]":
depth -= 1
if depth == 0:
return page_html[arr_start : i + 1]
continue
return ""
def _extract_json_shorties(page_html: str) -> list[dict]:
raw_array = _extract_balanced_json_array(page_html, _JSON_SHORTIES_MARKER)
if not raw_array:
return []
try:
parsed = json.loads(raw_array)
except json.JSONDecodeError:
logger.warning("Shorties parser: failed to decode JSON_SHORTIES array")
return []
if not isinstance(parsed, list):
return []
return [item for item in parsed if isinstance(item, dict) and item.get("videoTitle")]
def _pick_mp4_from_media_defs(media_defs: object) -> str:
if not isinstance(media_defs, list):
return ""
best_url = ""
best_quality = -1
for item in media_defs:
if not isinstance(item, dict):
continue
if str(item.get("format", "")).lower() != "mp4":
continue
raw_url = item.get("videoUrl")
if not isinstance(raw_url, str) or not raw_url:
continue
quality_raw = str(item.get("quality") or "").strip("p ")
try:
quality = int(quality_raw)
except ValueError:
quality = 0
if quality >= best_quality:
best_quality = quality
best_url = raw_url
return best_url.replace("\\/", "/")
def _pick_hls_from_media_defs(media_defs: object) -> str:
if not isinstance(media_defs, list):
return ""
best_url = ""
best_quality = -1
for item in media_defs:
if not isinstance(item, dict):
continue
if str(item.get("format", "")).lower() != "hls":
continue
raw_url = item.get("videoUrl")
if not isinstance(raw_url, str) or ".m3u8" not in raw_url:
continue
quality_raw = str(item.get("quality") or "").strip("p ")
try:
quality = int(quality_raw)
except ValueError:
quality = 0
if quality >= best_quality:
best_quality = quality
best_url = raw_url
return best_url.replace("\\/", "/")
def _is_russian_item(item: dict) -> bool:
title = str(item.get("videoTitle") or item.get("metaTitle") or "")
if _CYRILLIC_RE.search(title):
return True
pills = item.get("pillsData")
if isinstance(pills, list):
for pill in pills:
if not isinstance(pill, dict):
continue
values = [str(pill.get("name") or ""), str(pill.get("slug") or "")]
for raw in values:
normalized = raw.strip().lower()
if any(marker in normalized for marker in _RUSSIAN_MARKERS):
return True
return False
def _is_russian_feed_item(item: dict) -> bool:
title = str(item.get("title") or "")
lowered = title.lower()
return bool(_CYRILLIC_RE.search(title) or any(marker in lowered for marker in _RUSSIAN_MARKERS))
def _feed_from_json_shorties(items: list[dict]) -> list[dict]:
feed: list[dict] = []
seen: set[str] = set()
for item in items:
if not _is_russian_item(item):
continue
hls_url = _pick_hls_from_media_defs(item.get("mediaDefinitions"))
mp4_url = _pick_mp4_from_media_defs(item.get("mediaDefinitions"))
if not hls_url and not mp4_url:
continue
source = str(item.get("linkUrl") or item.get("uniqueUrl") or item.get("shortieUrl") or "").strip()
if source and source.startswith("/"):
source = _absolute_url(source)
key = source or hls_url or mp4_url
if key in seen:
continue
seen.add(key)
tracking = item.get("trackingTimeWatched")
duration_raw = ""
if isinstance(tracking, dict):
duration_raw = str(tracking.get("video_duration") or "").strip()
title = str(item.get("videoTitle") or item.get("metaTitle") or "Shorties").strip()
thumb = str(item.get("imageUrl") or "").strip().replace("\\/", "/")
views = str(item.get("likeInfo") or item.get("likeNumber") or "").strip()
favorites = str(item.get("favoriteInfo") or item.get("favoriteNumber") or "").strip()
embed_url = ""
embed_raw = item.get("embedUrl")
if isinstance(embed_raw, str) and embed_raw.strip():
unescaped = html.unescape(embed_raw.replace("\\/", "/"))
match = _EMBED_SRC_RE.search(unescaped)
if match:
embed_url = match.group(1).strip()
feed.append(
{
"type": "video",
"url": hls_url or mp4_url,
"sample": thumb or hls_url or mp4_url,
"ext": "mp4",
"score": 0,
"fav_count": favorites or "0",
"title": title,
"duration": duration_raw,
"views": views,
"source": source or _absolute_url("/shorties"),
"hls_url": hls_url,
"mp4_url": mp4_url,
"embed_url": embed_url,
}
)
return feed
def _extract_meta_fields(page_html: str) -> tuple[str, str]:
duration_match = _DURATION_RE.search(page_html)
views_match = _VIEWS_RE.search(page_html)
duration = duration_match.group("duration") if duration_match else ""
views = views_match.group("views") if views_match else ""
return duration, views
async def _resolve_video_candidate(
session: aiohttp.ClientSession,
item: ShortiesCandidate,
semaphore: asyncio.Semaphore,
) -> dict | None:
async with semaphore:
page_html = await _fetch_text(session, item.page_url)
if not page_html:
return None
mp4_url = _extract_best_mp4(page_html)
if not mp4_url:
return None
duration, views = _extract_meta_fields(page_html)
if not item.duration:
item.duration = duration
if not item.views:
item.views = views
return {
"type": "video",
"url": mp4_url,
"sample": item.thumb_url or mp4_url,
"ext": "mp4",
"score": 0,
"fav_count": 0,
"title": item.title,
"duration": item.duration or "",
"views": item.views or "",
"source": item.page_url,
}
async def fetch_shorties_feed(count: int = 8, random_pages: int = 3) -> list[dict]:
count = max(1, min(count, 20))
random_pages = max(1, min(random_pages, 2))
page_numbers = {1}
while len(page_numbers) < random_pages:
page_numbers.add(random.randint(1, 40))
list_urls = [f"{SHORTIES_LIST_URL}?page={page}" for page in sorted(page_numbers)]
timeout = aiohttp.ClientTimeout(total=20)
connector = aiohttp.TCPConnector(limit=16)
async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
list_pages = await asyncio.gather(*[_fetch_text(session, url) for url in list_urls], return_exceptions=True)
json_shorties_feed: list[dict] = []
for page in list_pages:
if isinstance(page, Exception) or not page:
continue
shorties_items = _extract_json_shorties(page)
if not shorties_items:
continue
json_shorties_feed.extend(_feed_from_json_shorties(shorties_items))
if json_shorties_feed:
russian_only = [item for item in json_shorties_feed if _is_russian_feed_item(item)]
# Строго пытаемся отдать russian first; если пусто — отдаём локализованную rt-ленту.
selected_feed = russian_only if russian_only else json_shorties_feed
random.shuffle(selected_feed)
# dedupe by source/url after enrichment
dedup: list[dict] = []
seen_keys: set[str] = set()
for item in selected_feed:
key = str(item.get("source") or item.get("url") or "").strip()
if not key or key in seen_keys:
continue
seen_keys.add(key)
dedup.append(item)
return dedup[:count]
candidates: list[ShortiesCandidate] = []
for page in list_pages:
if isinstance(page, Exception) or not page:
continue
candidates.extend(_extract_ldjson_candidates(page))
candidates.extend(_extract_anchor_candidates(page))
candidates = _dedupe_candidates(candidates)
if not candidates:
logger.warning("Shorties parser: no candidates found")
return []
random.shuffle(candidates)
candidates = candidates[: max(count * 3, 12)]
semaphore = asyncio.Semaphore(5)
resolved = await asyncio.gather(
*[_resolve_video_candidate(session, item, semaphore) for item in candidates],
return_exceptions=True,
)
feed: list[dict] = []
for item in resolved:
if isinstance(item, dict) and item.get("url"):
feed.append(item)
russian_only_fallback = [item for item in feed if _is_russian_feed_item(item)]
if russian_only_fallback:
feed = russian_only_fallback
random.shuffle(feed)
return feed[:count]