diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.env b/.env deleted file mode 100644 index 5ad177a..0000000 --- a/.env +++ /dev/null @@ -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 diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 2e99a10..d0b4594 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -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 = "" 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", diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..88356f1 --- /dev/null +++ b/DEPLOYMENT.md @@ -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` + +Бот готов к работе! 🎉 diff --git a/README.md b/README.md index 2c630c5..58454bd 100644 --- a/README.md +++ b/README.md @@ -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` лежит в корне. diff --git a/config.py b/config.py index f95ba63..b0c2354 100644 --- a/config.py +++ b/config.py @@ -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 --- diff --git a/db.py b/db.py new file mode 100644 index 0000000..d98fedb --- /dev/null +++ b/db.py @@ -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) diff --git a/docker-compose.webapp-test.yml b/docker-compose.webapp-test.yml new file mode 100644 index 0000000..43ea2ee --- /dev/null +++ b/docker-compose.webapp-test.yml @@ -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 diff --git a/docker-compose.yaml b/docker-compose.yaml index 1fff538..c25410f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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: diff --git a/games/betting.py b/games/betting.py index d805de8..89570d0 100644 --- a/games/betting.py +++ b/games/betting.py @@ -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", "") diff --git a/games/casino.py b/games/casino.py index 1d4e744..140ba6d 100644 --- a/games/casino.py +++ b/games/casino.py @@ -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} см" ) diff --git a/games/economy.py b/games/economy.py new file mode 100644 index 0000000..4308875 --- /dev/null +++ b/games/economy.py @@ -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() diff --git a/games/nude.py b/games/nude.py index b1c024d..fec2b61 100644 --- a/games/nude.py +++ b/games/nude.py @@ -18,6 +18,7 @@ CAPTIONS = [ "Шо ты блять псюнчик решил свой подергать?", "Решил подрочить сука?", "гнойный писюн у тебя кнч, да", + "Ух нихуя, это ШПАЧИХА!?", ] LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts" diff --git a/main.py b/main.py index 02b60ef..7e20f49 100644 --- a/main.py +++ b/main.py @@ -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** + +🤖 **Децентрализованная система:** +• ЦБ работает полностью автоматически +• Никакого ручного вмешательства +• Все решения принимаются по алгоритмам + +� **Сбор налогов:** +• 13% налог автоматически поступает в ЦБ +• Налоги собираются каждый час +• Капитал ЦБ растет за счет налогов + +📈 **Борьба с инфляцией:** +• Если инфляция > 10%: ЦБ скупает деньги +• Увеличивает налоги до 20% максимум +• Скупает до 5% от массы денег + +� **Стимуляция экономики:** +• Если инфляция < 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(), @@ -1498,6 +1709,9 @@ def main(): await bot.set_my_commands(commands, scope=scope) except Exception: logger.warning("Не удалось выставить команды, но бот продолжит работу.") + + # Запуск фоновых задач экономики + asyncio.create_task(economy_background_tasks()) dp.startup.register(on_startup) @@ -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() diff --git a/requirements.txt b/requirements.txt index 8a88b7b..3a0a60e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,6 @@ pillow>=10.3.0 requests>=2.31.0 aiohttp>=3.9.5 tzdata>=2024.1 -beautifulsoup4 +beautifulsoup4 lxml -pysqlite3-binary>=0.5.3 +psycopg2-binary>=2.9.9 diff --git a/scripts/migrate_sqlite_to_pg.py b/scripts/migrate_sqlite_to_pg.py new file mode 100644 index 0000000..66732ed --- /dev/null +++ b/scripts/migrate_sqlite_to_pg.py @@ -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() diff --git a/scripts/set_telegram_webapp_button.py b/scripts/set_telegram_webapp_button.py new file mode 100755 index 0000000..a310a81 --- /dev/null +++ b/scripts/set_telegram_webapp_button.py @@ -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()) diff --git a/scripts/start_webapp_ngrok_test.sh b/scripts/start_webapp_ngrok_test.sh new file mode 100755 index 0000000..01d432b --- /dev/null +++ b/scripts/start_webapp_ngrok_test.sh @@ -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" diff --git a/scripts/stop_webapp_ngrok_test.sh b/scripts/stop_webapp_ngrok_test.sh new file mode 100755 index 0000000..3d3a908 --- /dev/null +++ b/scripts/stop_webapp_ngrok_test.sh @@ -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 diff --git a/webapp/api.py b/webapp/api.py index 83ca6b2..c85a18a 100644 --- a/webapp/api.py +++ b/webapp/api.py @@ -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") diff --git a/webapp/frontend/css/style.css b/webapp/frontend/css/style.css index 47d4970..b78ee7e 100644 --- a/webapp/frontend/css/style.css +++ b/webapp/frontend/css/style.css @@ -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; diff --git a/webapp/frontend/index.html b/webapp/frontend/index.html index 02a7753..31497d3 100644 --- a/webapp/frontend/index.html +++ b/webapp/frontend/index.html @@ -10,6 +10,7 @@ +
diff --git a/webapp/frontend/js/api.js b/webapp/frontend/js/api.js index 2d5522f..330203a 100644 --- a/webapp/frontend/js/api.js +++ b/webapp/frontend/js/api.js @@ -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 } }), }; diff --git a/webapp/frontend/js/app.js b/webapp/frontend/js/app.js index 7bc14be..3b7fd08 100644 --- a/webapp/frontend/js/app.js +++ b/webapp/frontend/js/app.js @@ -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('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +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 = `
-
🐺 FurTok
+
${furtokMode === 'shorties' ? '🔥 Shorties' : '🐺 FurTok'}
+
+ + +