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 random
import re
import sqlite3
import time
from io import BytesIO
from pathlib import Path
from typing import Any
import aiohttp
import psycopg2.extras
from aiogram.types import Message
from db import get_conn
logger = logging.getLogger(__name__)
# Основные настройки поведения и стиля бота редактируются здесь.
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or getattr(__import__("config"), "CHAT_HISTORY_DB_PATH", str(Path(__file__).resolve().with_name("chat_history.sqlite3")))
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
SKIP_TOKEN = "<skip>"
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
@ -237,6 +236,7 @@ AUTONOMOUS_SIGNAL_RE = re.compile(
re.IGNORECASE,
)
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()
_summary_lock = asyncio.Lock()
@ -244,133 +244,95 @@ _cached_bot_id: int | None = None
_cached_bot_username: str = ""
def _connect_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout = 30000")
return conn
def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any(row["name"] == column for row in rows)
def _get_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, default: str = "") -> str:
row = conn.execute(
"SELECT value FROM chat_state WHERE chat_id = ? AND key = ?",
def _get_state_from_conn(conn, chat_id: int, key: str, default: str = "") -> str:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT value FROM chat_state WHERE chat_id = %s AND key = %s",
(chat_id, key),
).fetchone()
)
row = cur.fetchone()
return row["value"] if row else default
def _set_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, value: str) -> None:
conn.execute(
def _set_state_from_conn(conn, chat_id: int, key: str, value: str) -> None:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO chat_state (chat_id, key, value)
VALUES (?, ?, ?)
ON CONFLICT(chat_id, key) DO UPDATE SET value = excluded.value
VALUES (%s, %s, %s)
ON CONFLICT (chat_id, key) DO UPDATE SET value = EXCLUDED.value
""",
(chat_id, key, value),
)
def _get_talk_state_from_conn(
conn: sqlite3.Connection,
chat_id: int,
user_id: int,
key: str,
default: str = "",
) -> str:
row = conn.execute(
"SELECT value FROM talk_state WHERE chat_id = ? AND user_id = ? AND key = ?",
def _get_talk_state_from_conn(conn, chat_id: int, user_id: int, key: str, default: str = "") -> str:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT value FROM talk_state WHERE chat_id = %s AND user_id = %s AND key = %s",
(chat_id, user_id, key),
).fetchone()
)
row = cur.fetchone()
return row["value"] if row else default
def _set_talk_state_from_conn(
conn: sqlite3.Connection,
chat_id: int,
user_id: int,
key: str,
value: str,
) -> None:
conn.execute(
def _set_talk_state_from_conn(conn, chat_id: int, user_id: int, key: str, value: str) -> None:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO talk_state (chat_id, user_id, key, value)
VALUES (?, ?, ?, ?)
ON CONFLICT(chat_id, user_id, key) DO UPDATE SET value = excluded.value
VALUES (%s, %s, %s, %s)
ON CONFLICT (chat_id, user_id, key) DO UPDATE SET value = EXCLUDED.value
""",
(chat_id, user_id, key, value),
)
def _init_db() -> None:
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
with _connect_db() as conn:
conn.execute("PRAGMA journal_mode = WAL")
conn.execute(
"""
with get_conn() as conn:
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
id BIGSERIAL PRIMARY KEY,
chat_id BIGINT NOT NULL,
role TEXT NOT NULL,
name TEXT NOT NULL,
text TEXT NOT NULL,
created_at REAL NOT NULL DEFAULT 0
created_at DOUBLE PRECISION NOT NULL DEFAULT 0
)
"""
)
if not _column_exists(conn, "chat_history", "created_at"):
conn.execute("ALTER TABLE chat_history ADD COLUMN created_at REAL NOT NULL DEFAULT 0")
conn.execute(
"""
""")
cur.execute("ALTER TABLE chat_history ADD COLUMN IF NOT EXISTS created_at DOUBLE PRECISION NOT NULL DEFAULT 0")
cur.execute("""
CREATE TABLE IF NOT EXISTS chat_state (
chat_id INTEGER NOT NULL,
chat_id BIGINT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (chat_id, key)
)
"""
)
conn.execute(
"""
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS talk_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
id BIGSERIAL PRIMARY KEY,
chat_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
role TEXT NOT NULL,
name TEXT NOT NULL,
text TEXT NOT NULL,
created_at REAL NOT NULL DEFAULT 0
created_at DOUBLE PRECISION NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"""
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS talk_state (
chat_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
chat_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (chat_id, user_id, key)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)"
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id
ON talk_history(chat_id, user_id, id)
"""
)
conn.commit()
""")
cur.execute("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_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id ON talk_history(chat_id, user_id, id)")
def _clean_text(text: str) -> str:
@ -420,78 +382,59 @@ def push_message(
cleaned_text = _clean_text(text)
if not cleaned_text:
return
with _connect_db() as conn:
with get_conn() as conn:
cur = conn.cursor()
if user_id is None:
conn.execute(
"""
INSERT INTO chat_history (chat_id, role, name, text, created_at)
VALUES (?, ?, ?, ?, ?)
""",
cur.execute(
"INSERT INTO chat_history (chat_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s)",
(chat_id, role, name, cleaned_text, time.time()),
)
else:
conn.execute(
"""
INSERT INTO talk_history (chat_id, user_id, role, name, text, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
cur.execute(
"INSERT INTO talk_history (chat_id, user_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s, %s)",
(chat_id, user_id, role, name, cleaned_text, time.time()),
)
conn.commit()
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:
return _get_state_from_conn(conn, chat_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]:
with _connect_db() as conn:
def _get_history_rows(chat_id: int, user_id: int | None = None) -> list:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
if user_id is None:
return conn.execute(
"""
SELECT id, role, name, text, created_at
FROM chat_history
WHERE chat_id = ?
ORDER BY id ASC
""",
cur.execute(
"SELECT id, role, name, text, created_at FROM chat_history WHERE chat_id = %s ORDER BY id ASC",
(chat_id,),
).fetchall()
return conn.execute(
"""
SELECT id, role, name, text, created_at
FROM talk_history
WHERE chat_id = ? AND user_id = ?
ORDER BY id ASC
""",
(chat_id, user_id),
).fetchall()
)
else:
cur.execute(
"SELECT id, role, name, text, created_at FROM talk_history WHERE chat_id = %s AND user_id = %s ORDER BY id ASC",
(chat_id, user_id),
)
return cur.fetchall()
def _latest_reply_stats(chat_id: int) -> tuple[float, int]:
with _connect_db() as conn:
last_assistant = conn.execute(
"""
SELECT id, created_at
FROM chat_history
WHERE chat_id = ? AND role = 'assistant'
ORDER BY id DESC
LIMIT 1
""",
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT id, created_at FROM chat_history WHERE chat_id = %s AND role = 'assistant' ORDER BY id DESC LIMIT 1",
(chat_id,),
).fetchone()
)
last_assistant = cur.fetchone()
if not last_assistant:
return 0.0, 10_000
user_messages_since_reply = conn.execute(
"""
SELECT COUNT(*)
FROM chat_history
WHERE chat_id = ? AND role = 'user' AND id > ?
""",
cnt_cur = conn.cursor()
cnt_cur.execute(
"SELECT COUNT(*) FROM chat_history WHERE chat_id = %s AND role = 'user' AND id > %s",
(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)
@ -501,23 +444,23 @@ def _store_summary_and_prune(
last_row_id: int,
user_id: int | None = None,
) -> None:
with _connect_db() as conn:
with get_conn() as conn:
cur = conn.cursor()
if user_id is None:
_set_state_from_conn(conn, chat_id, "summary", summary)
conn.execute(
"DELETE FROM chat_history WHERE chat_id = ? AND id <= ?",
cur.execute(
"DELETE FROM chat_history WHERE chat_id = %s AND id <= %s",
(chat_id, last_row_id),
)
else:
_set_talk_state_from_conn(conn, chat_id, user_id, "summary", summary)
conn.execute(
"DELETE FROM talk_history WHERE chat_id = ? AND user_id = ? AND id <= ?",
cur.execute(
"DELETE FROM talk_history WHERE chat_id = %s AND user_id = %s AND id <= %s",
(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":
return {
"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/"))
if not (message.photo or is_image_document) or not message.from_user or message.from_user.is_bot:
return False
if message.caption and message.caption.startswith("/"):
watch_match = WATCH_COMMAND_RE.match((message.caption or "").strip())
if not watch_match:
return False
chat_id = message.chat.id
user_name = message.from_user.first_name or "кент"
caption = _clean_text(message.caption or "")
caption = _clean_text(watch_match.group(1) or "")
logger.warning(
"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).
Контейнер хранит состояние в `./db`:
- `penis_stats.sqlite3`
- `chat_history.sqlite3`
- `polychaetsi_stats.json`
- `bets.sqlite3`
- `nude_history.json`
- `penis_stats.sqlite3` — статистика игры /penis
- `chat_history.sqlite3` — история для ИИ-общения
- `polychaetsi_stats.json` — статистика слова "получается"
- `bets.sqlite3` — ставки на спорт
- `economy.sqlite3` — экономическая система (балансы, вклады, кредиты)
- `nude_history.json` — история запросов /nude
## Команды
@ -63,6 +64,39 @@ docker compose down
- `/mybets` — мои активные ставки.
- `/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` лежит в корне.

View file

@ -7,10 +7,9 @@ from zoneinfo import ZoneInfo
BASE_DIR = Path(__file__).parent
FONT_PATH = BASE_DIR / "impact.ttf"
DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow")
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
# --- Настройки /penis ---
PENIS_DB_PATH = "/db/penis_stats.sqlite3"
CHAT_HISTORY_DB_PATH = "/db/chat_history.sqlite3"
PENIS_START_LENGTH = 10.0
PENIS_MIN_DELTA = 0.1
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_BASE = "https://api.the-odds-api.com/v4"
BET_MAX_AMOUNT = 3.0
ODDS_SPORTS = {
"epl": "soccer_epl",
"ucl": "soccer_uefa_champs_league",
@ -43,8 +43,6 @@ ODDS_SPORTS = {
"ligue1": "soccer_france_ligue_one",
"europa": "soccer_uefa_europa_league",
}
BET_MAX_AMOUNT = 3.0
BET_DB_PATH = "/db/bets.sqlite3"
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
# --- 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:
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:
build:
context: .
@ -8,13 +23,15 @@ services:
- .env
environment:
TZ: Europe/Moscow
PENIS_DB_PATH: /db/penis_stats.sqlite3
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
DATABASE_URL: postgresql://bot:${POSTGRES_PASSWORD:-botpass}@postgres:5432/botdb
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_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
depends_on:
postgres:
condition: service_healthy
init: true
restart: unless-stopped
@ -28,14 +45,16 @@ services:
- .env
environment:
TZ: Europe/Moscow
PENIS_DB_PATH: /db/penis_stats.sqlite3
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
DATABASE_URL: postgresql://bot:${POSTGRES_PASSWORD:-botpass}@postgres:5432/botdb
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
BET_DB_PATH: /db/bets.sqlite3
NUDE_HISTORY_PATH: /db/nude_history.json
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}
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
webapp-frontend:
@ -58,5 +77,6 @@ services:
restart: unless-stopped
volumes:
postgres_data:
certbot_etc:
certbot_www:

View file

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

View file

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

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 datetime import datetime, time, timedelta
from io import BytesIO
@ -39,6 +39,7 @@ from games.betting import (
resolve_match_outcome,
settle_bets,
)
from games.economy import economy
from zparser import get_military_data
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):
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:
"""Инициализация json-хранилища статистики слова 'получается'."""
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}"
try:
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT length, last_used_ts, display_name FROM penis_stats WHERE user_id = ?",
(user_id,),
).fetchone()
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:
result = economy.play_penis(
user_id, display_name, now_ts,
config.PENIS_START_LENGTH, config.PENIS_COOLDOWN_SECONDS,
config.PENIS_MIN_DELTA, config.PENIS_MAX_DELTA,
)
except Exception:
logger.exception("Failed to process /penis")
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 "-"
return (
True,
f"📏 Изменение: {sign_text}{delta:.1f} см\n"
f"Текущая длина: {new_length:.1f} см\n"
f"Текущая длина: {new_balance:.1f} см\n"
"Следующая попытка через 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:
try:
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
conn.row_factory = sqlite3.Row
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:
rows = economy.get_penis_top(limit)
except Exception:
logger.exception("Failed to build /top_penis")
return "Не удалось загрузить топ. Ошибка БД."
@ -812,11 +760,10 @@ def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str:
lines = ["🏆 Топ по длине:"]
for index, row in enumerate(rows, start=1):
user_id = int(row["user_id"])
length = float(row["length"])
name = row["display_name"] if isinstance(row["display_name"], str) else f"user_{user_id}"
name = name.strip() if name.strip() else f"user_{user_id}"
balance = float(row["balance"])
name = str(row["display_name"]).strip() or f"user_{user_id}"
prefix = medals.get(index, f"{index}.")
lines.append(f"{prefix} {name}{length:.1f} см")
lines.append(f"{prefix} {name}{balance:.1f} см")
return "\n".join(lines)
def _load_polychaetsi_stats() -> dict:
@ -1195,10 +1142,19 @@ _autoreply_disabled_chats: set[int] = set()
async def handle_keywords(message: Message):
if not message.text or message.text.startswith('/'):
return
# Считаем сообщение для экономической системы
if message.from_user:
await asyncio.to_thread(economy.count_message, message.from_user.id)
text_lower = message.text.lower()
chat_id = message.chat.id
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):
audio_path = config.BASE_DIR / "maket" / "Radiohead - Creep.mp3"
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)
if chat_id in _autoreply_disabled_chats:
return
await handle_chat_message(message, store_message=False, allow_autonomous=True)
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)
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):
parts = message.text.split(maxsplit=1)
if len(parts) < 2 or not parts[1].strip():
@ -1424,7 +1615,6 @@ def main():
token = os.getenv("BOT_TOKEN")
if not token: raise RuntimeError("BOT_TOKEN не задан!")
init_penis_db()
init_polychaetsi_storage()
bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher()
@ -1456,7 +1646,20 @@ def main():
dp.message.register(handle_gen_mem, Command("gen_mem"))
dp.message.register(handle_uwu_cmd, Command("uwu"))
dp.message.register(handle_nude_cmd, Command("nude"))
dp.message.register(handle_photo_message, _is_supported_image_message)
dp.message.register(handle_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_unmatched_message_debug)
@ -1477,6 +1680,7 @@ def main():
BotCommand(command="fetch", description="alias для /prices"),
BotCommand(command="zvetok", description="Цветянский бля"),
BotCommand(command="talk", description="Побазарить"),
BotCommand(command="watch", description="Коммент фото по подписи /watch"),
BotCommand(command="ebalnik", description="вкл/выкл автответов"),
BotCommand(command="penis_casino", description="казино на размер"),
BotCommand(command="gadanie", description="гадание на фене"),
@ -1488,6 +1692,13 @@ def main():
BotCommand(command="svodka", description="СВО: итоги"),
BotCommand(command="uwu", description="Случайная картинка с e621"),
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 = (
BotCommandScopeDefault(),
@ -1499,6 +1710,9 @@ def main():
except Exception:
logger.warning("Не удалось выставить команды, но бот продолжит работу.")
# Запуск фоновых задач экономики
asyncio.create_task(economy_background_tasks())
dp.startup.register(on_startup)
async def start_settle(bot: Bot):
@ -1508,5 +1722,29 @@ def main():
logger.info("Бот запущен...")
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__":
main()

View file

@ -5,4 +5,4 @@ aiohttp>=3.9.5
tzdata>=2024.1
beautifulsoup4
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 hashlib
from urllib.parse import quote
from urllib.parse import urljoin
from urllib.parse import urlparse
import sys
from contextlib import asynccontextmanager
from typing import Annotated
@ -17,7 +19,7 @@ from typing import Annotated
import aiohttp
from fastapi import FastAPI, Depends, Header, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
from fastapi.responses import Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
@ -36,6 +38,7 @@ from games.betting import (
)
from webapp.auth import validate_init_data
from games.uwu import fetch_uwu_post, fetch_furtok_feed
from webapp.shorties import fetch_shorties_feed
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@ -142,15 +145,16 @@ async def get_profile(user: dict = Depends(get_current_user)) -> ProfileResponse
user_id = user["user_id"]
length = await asyncio.to_thread(get_user_length, user_id)
# Считаем активные ставки
import sqlite3
active_bets = 0
try:
with sqlite3.connect(config.BET_DB_PATH) as conn:
row = conn.execute(
"SELECT COUNT(*) FROM bets WHERE user_id = ? AND status = 'pending'",
from db import get_conn
with get_conn() as conn:
cur = conn.cursor()
cur.execute(
"SELECT COUNT(*) FROM bets WHERE user_id = %s AND status = 'pending'",
(user_id,),
).fetchone()
)
row = cur.fetchone()
active_bets = row[0] if row else 0
except Exception:
pass
@ -405,6 +409,23 @@ async def get_furtok_feed_api(
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/furtok/shorties")
async def get_shorties_feed_api(
page: int = 1,
count: int = 8,
user: dict = Depends(get_current_user),
):
"""Случайная shorties-лента с Pornhub."""
try:
# page используем как "seed-сдвиг" для подгрузки следующих рандомных наборов.
random_pages = 2 + min(max(page, 1), 6)
feed = await fetch_shorties_feed(count=count, random_pages=random_pages)
return {"feed": feed}
except Exception as e:
logger.exception("Shorties API failed")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/proxy-image")
async def proxy_image(url: str):
"""Проксирует картинку с e621 чтобы обойти hotlink protection."""
@ -433,6 +454,119 @@ async def proxy_image(url: str):
raise HTTPException(status_code=502, detail="Failed to fetch image")
def _is_allowed_shorties_media_host(hostname: str) -> bool:
host = (hostname or "").lower()
if not host:
return False
if host in {"www.pornhub.com", "pornhub.com", "phncdn.com"}:
return True
if host.endswith(".phncdn.com"):
return True
return False
@app.get("/api/proxy-shorties-media")
async def proxy_shorties_media(url: str, request: Request):
"""Проксирует видео shorties, чтобы избежать ограничений hotlink/CORS."""
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not _is_allowed_shorties_media_host(parsed.hostname or ""):
raise HTTPException(status_code=403, detail="Forbidden host")
headers = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Referer": "https://www.pornhub.com/shorties",
"Origin": "https://www.pornhub.com",
}
range_header = request.headers.get("range")
if range_header:
headers["Range"] = range_header
timeout = aiohttp.ClientTimeout(total=120)
session = aiohttp.ClientSession(timeout=timeout)
try:
resp = await session.get(url, headers=headers, allow_redirects=True)
except aiohttp.ClientError:
await session.close()
raise HTTPException(status_code=502, detail="Failed to fetch media")
if resp.status not in {200, 206}:
await resp.release()
await session.close()
raise HTTPException(status_code=resp.status, detail="Upstream error")
content_type = resp.headers.get("Content-Type", "application/octet-stream")
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")

View file

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

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 rel="stylesheet" href="css/style.css">
<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>
<body>
<div id="app">

View file

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

View file

@ -33,6 +33,12 @@ const navBtns = document.querySelectorAll('.nav-btn');
// ── Router ──
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;
haptic();
// Убираем оверлеи при смене страницы
@ -66,6 +72,82 @@ function $(html) {
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') {
return new Promise(resolve => {
const overlay = $(`
@ -507,6 +589,7 @@ let furtokWrapper = null;
let furtokCurrentIndex = 0;
let furtokCards = [];
let furtokCustomTags = '';
let furtokMode = 'furtok'; // "furtok" | "shorties"
// Подписи из uwu.py — рандомно появляются на карточках
const FURTOK_CAPTIONS = [
@ -531,6 +614,7 @@ function randomCaption() {
}
function _furtokGetTags() {
if (furtokMode === 'shorties') return '';
// Если кастомные теги заданы — используем их (safe управляется юзером через теги)
if (furtokCustomTags.trim()) return furtokCustomTags.trim();
// Иначе стандартный запрос с safe-переключателем
@ -546,6 +630,29 @@ function _furtokReload(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() {
container.innerHTML = '';
document.querySelector('.furtok-wrapper')?.remove();
@ -559,8 +666,12 @@ function renderFurtok() {
furtokWrapper.className = 'furtok-wrapper';
furtokWrapper.innerHTML = `
<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-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"' : ''}>
<span>Safe</span>
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}>
@ -588,6 +699,23 @@ function renderFurtok() {
const tagsPanel = document.getElementById('furtok-tags-panel');
const tagsInput = document.getElementById('furtok-tags-input');
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
safeToggle.addEventListener('change', () => {
@ -608,7 +736,7 @@ function renderFurtok() {
haptic();
furtokCustomTags = tagsInput.value.trim();
// Если кастомные теги — прячем Safe (юзер сам контролирует rating)
safeWrap.style.display = furtokCustomTags ? 'none' : '';
_updateFurtokUiMode();
tagsPanel.style.display = 'none';
_furtokReload(feed);
});
@ -727,7 +855,9 @@ async function loadFurtokPage(feedEl) {
feedEl.appendChild(loader);
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();
if (!res.feed || res.feed.length === 0) {
@ -741,23 +871,44 @@ async function loadFurtokPage(feedEl) {
res.feed.forEach(post => {
const card = document.createElement('div');
card.className = 'furtok-card';
const isShorties = furtokMode === 'shorties';
const shareUrl = getShortiesShareUrl(post);
let mediaHtml = '';
if (post.type === 'image' || post.ext === 'gif') {
mediaHtml = `<img src="${post.url}" loading="lazy">`;
mediaHtml = `<img src="${post.url}" loading="lazy" alt="">`;
} 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 = `
${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-caption">${caption}</div>
<div class="furtok-stats">
<span> ${post.score}</span>
<span> ${post.fav_count}</span>
<span>${scoreLabel}</span>
<span>${favLabel}</span>
</div>
</div>
`;
@ -765,6 +916,15 @@ async function loadFurtokPage(feedEl) {
// Тап по видео = unmute + play/pause
const video = card.querySelector('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', () => {
video.muted = false;
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);
furtokCards.push(card);
});
@ -779,7 +956,10 @@ async function loadFurtokPage(feedEl) {
// Автоплей первого видео при первой загрузке
if (furtokCurrentIndex === 0 && furtokCards.length > 0) {
const firstVideo = furtokCards[0].querySelector('video');
if (firstVideo) firstVideo.play().catch(() => {});
if (firstVideo) {
firstVideo.muted = true;
firstVideo.play().catch(() => {});
}
}
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-Proto $scheme;
proxy_set_header X-Telegram-Init-Data $http_x_telegram_init_data;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 300s;
}
location / {

View file

@ -1,2 +1,3 @@
fastapi>=0.111.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]