Merge remote-tracking branch 'upstream/main' into feature/FenyaBot
This commit is contained in:
commit
dd3c993146
27 changed files with 2878 additions and 457 deletions
0
.codex
Normal file
0
.codex
Normal file
7
.env
7
.env
|
|
@ -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
|
|
||||||
|
|
@ -7,20 +7,19 @@ import mimetypes
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
|
||||||
import time
|
import time
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import psycopg2.extras
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from db import get_conn
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Основные настройки поведения и стиля бота редактируются здесь.
|
|
||||||
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
||||||
DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or getattr(__import__("config"), "CHAT_HISTORY_DB_PATH", str(Path(__file__).resolve().with_name("chat_history.sqlite3")))
|
|
||||||
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
|
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
|
||||||
SKIP_TOKEN = "<skip>"
|
SKIP_TOKEN = "<skip>"
|
||||||
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
|
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
|
||||||
|
|
@ -237,6 +236,7 @@ AUTONOMOUS_SIGNAL_RE = re.compile(
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]")
|
LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]")
|
||||||
|
WATCH_COMMAND_RE = re.compile(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL)
|
||||||
|
|
||||||
_reply_lock = asyncio.Lock()
|
_reply_lock = asyncio.Lock()
|
||||||
_summary_lock = asyncio.Lock()
|
_summary_lock = asyncio.Lock()
|
||||||
|
|
@ -244,133 +244,95 @@ _cached_bot_id: int | None = None
|
||||||
_cached_bot_username: str = ""
|
_cached_bot_username: str = ""
|
||||||
|
|
||||||
|
|
||||||
def _connect_db() -> sqlite3.Connection:
|
def _get_state_from_conn(conn, chat_id: int, key: str, default: str = "") -> str:
|
||||||
conn = sqlite3.connect(DB_PATH, timeout=30)
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
conn.row_factory = sqlite3.Row
|
cur.execute(
|
||||||
conn.execute("PRAGMA busy_timeout = 30000")
|
"SELECT value FROM chat_state WHERE chat_id = %s AND key = %s",
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
|
||||||
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
|
||||||
return any(row["name"] == column for row in rows)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, default: str = "") -> str:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT value FROM chat_state WHERE chat_id = ? AND key = ?",
|
|
||||||
(chat_id, key),
|
(chat_id, key),
|
||||||
).fetchone()
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
return row["value"] if row else default
|
return row["value"] if row else default
|
||||||
|
|
||||||
|
|
||||||
def _set_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, value: str) -> None:
|
def _set_state_from_conn(conn, chat_id: int, key: str, value: str) -> None:
|
||||||
conn.execute(
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO chat_state (chat_id, key, value)
|
INSERT INTO chat_state (chat_id, key, value)
|
||||||
VALUES (?, ?, ?)
|
VALUES (%s, %s, %s)
|
||||||
ON CONFLICT(chat_id, key) DO UPDATE SET value = excluded.value
|
ON CONFLICT (chat_id, key) DO UPDATE SET value = EXCLUDED.value
|
||||||
""",
|
""",
|
||||||
(chat_id, key, value),
|
(chat_id, key, value),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_talk_state_from_conn(
|
def _get_talk_state_from_conn(conn, chat_id: int, user_id: int, key: str, default: str = "") -> str:
|
||||||
conn: sqlite3.Connection,
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
chat_id: int,
|
cur.execute(
|
||||||
user_id: int,
|
"SELECT value FROM talk_state WHERE chat_id = %s AND user_id = %s AND key = %s",
|
||||||
key: str,
|
|
||||||
default: str = "",
|
|
||||||
) -> str:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT value FROM talk_state WHERE chat_id = ? AND user_id = ? AND key = ?",
|
|
||||||
(chat_id, user_id, key),
|
(chat_id, user_id, key),
|
||||||
).fetchone()
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
return row["value"] if row else default
|
return row["value"] if row else default
|
||||||
|
|
||||||
|
|
||||||
def _set_talk_state_from_conn(
|
def _set_talk_state_from_conn(conn, chat_id: int, user_id: int, key: str, value: str) -> None:
|
||||||
conn: sqlite3.Connection,
|
cur = conn.cursor()
|
||||||
chat_id: int,
|
cur.execute(
|
||||||
user_id: int,
|
|
||||||
key: str,
|
|
||||||
value: str,
|
|
||||||
) -> None:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
"""
|
||||||
INSERT INTO talk_state (chat_id, user_id, key, value)
|
INSERT INTO talk_state (chat_id, user_id, key, value)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (%s, %s, %s, %s)
|
||||||
ON CONFLICT(chat_id, user_id, key) DO UPDATE SET value = excluded.value
|
ON CONFLICT (chat_id, user_id, key) DO UPDATE SET value = EXCLUDED.value
|
||||||
""",
|
""",
|
||||||
(chat_id, user_id, key, value),
|
(chat_id, user_id, key, value),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _init_db() -> None:
|
def _init_db() -> None:
|
||||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
with get_conn() as conn:
|
||||||
with _connect_db() as conn:
|
cur = conn.cursor()
|
||||||
conn.execute("PRAGMA journal_mode = WAL")
|
cur.execute("""
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS chat_history (
|
CREATE TABLE IF NOT EXISTS chat_history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
chat_id INTEGER NOT NULL,
|
chat_id BIGINT NOT NULL,
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
text TEXT NOT NULL,
|
text TEXT NOT NULL,
|
||||||
created_at REAL NOT NULL DEFAULT 0
|
created_at DOUBLE PRECISION NOT NULL DEFAULT 0
|
||||||
)
|
)
|
||||||
"""
|
""")
|
||||||
)
|
cur.execute("ALTER TABLE chat_history ADD COLUMN IF NOT EXISTS created_at DOUBLE PRECISION NOT NULL DEFAULT 0")
|
||||||
if not _column_exists(conn, "chat_history", "created_at"):
|
cur.execute("""
|
||||||
conn.execute("ALTER TABLE chat_history ADD COLUMN created_at REAL NOT NULL DEFAULT 0")
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS chat_state (
|
CREATE TABLE IF NOT EXISTS chat_state (
|
||||||
chat_id INTEGER NOT NULL,
|
chat_id BIGINT NOT NULL,
|
||||||
key TEXT NOT NULL,
|
key TEXT NOT NULL,
|
||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
PRIMARY KEY (chat_id, key)
|
PRIMARY KEY (chat_id, key)
|
||||||
)
|
)
|
||||||
"""
|
""")
|
||||||
)
|
cur.execute("""
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS talk_history (
|
CREATE TABLE IF NOT EXISTS talk_history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
chat_id INTEGER NOT NULL,
|
chat_id BIGINT NOT NULL,
|
||||||
user_id INTEGER NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
text TEXT NOT NULL,
|
text TEXT NOT NULL,
|
||||||
created_at REAL NOT NULL DEFAULT 0
|
created_at DOUBLE PRECISION NOT NULL DEFAULT 0
|
||||||
)
|
)
|
||||||
"""
|
""")
|
||||||
)
|
cur.execute("""
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS talk_state (
|
CREATE TABLE IF NOT EXISTS talk_state (
|
||||||
chat_id INTEGER NOT NULL,
|
chat_id BIGINT NOT NULL,
|
||||||
user_id INTEGER NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
key TEXT NOT NULL,
|
key TEXT NOT NULL,
|
||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
PRIMARY KEY (chat_id, user_id, key)
|
PRIMARY KEY (chat_id, user_id, key)
|
||||||
)
|
)
|
||||||
"""
|
""")
|
||||||
)
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)")
|
||||||
conn.execute(
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)")
|
||||||
"CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)"
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id ON talk_history(chat_id, user_id, id)")
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)"
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id
|
|
||||||
ON talk_history(chat_id, user_id, id)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_text(text: str) -> str:
|
def _clean_text(text: str) -> str:
|
||||||
|
|
@ -420,78 +382,59 @@ def push_message(
|
||||||
cleaned_text = _clean_text(text)
|
cleaned_text = _clean_text(text)
|
||||||
if not cleaned_text:
|
if not cleaned_text:
|
||||||
return
|
return
|
||||||
with _connect_db() as conn:
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
conn.execute(
|
cur.execute(
|
||||||
"""
|
"INSERT INTO chat_history (chat_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s)",
|
||||||
INSERT INTO chat_history (chat_id, role, name, text, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(chat_id, role, name, cleaned_text, time.time()),
|
(chat_id, role, name, cleaned_text, time.time()),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
conn.execute(
|
cur.execute(
|
||||||
"""
|
"INSERT INTO talk_history (chat_id, user_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s, %s)",
|
||||||
INSERT INTO talk_history (chat_id, user_id, role, name, text, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(chat_id, user_id, role, name, cleaned_text, time.time()),
|
(chat_id, user_id, role, name, cleaned_text, time.time()),
|
||||||
)
|
)
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_summary(chat_id: int, user_id: int | None = None) -> str:
|
def _get_summary(chat_id: int, user_id: int | None = None) -> str:
|
||||||
with _connect_db() as conn:
|
with get_conn() as conn:
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
return _get_state_from_conn(conn, chat_id, "summary", "")
|
return _get_state_from_conn(conn, chat_id, "summary", "")
|
||||||
return _get_talk_state_from_conn(conn, chat_id, user_id, "summary", "")
|
return _get_talk_state_from_conn(conn, chat_id, user_id, "summary", "")
|
||||||
|
|
||||||
|
|
||||||
def _get_history_rows(chat_id: int, user_id: int | None = None) -> list[sqlite3.Row]:
|
def _get_history_rows(chat_id: int, user_id: int | None = None) -> list:
|
||||||
with _connect_db() as conn:
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
return conn.execute(
|
cur.execute(
|
||||||
"""
|
"SELECT id, role, name, text, created_at FROM chat_history WHERE chat_id = %s ORDER BY id ASC",
|
||||||
SELECT id, role, name, text, created_at
|
|
||||||
FROM chat_history
|
|
||||||
WHERE chat_id = ?
|
|
||||||
ORDER BY id ASC
|
|
||||||
""",
|
|
||||||
(chat_id,),
|
(chat_id,),
|
||||||
).fetchall()
|
)
|
||||||
return conn.execute(
|
else:
|
||||||
"""
|
cur.execute(
|
||||||
SELECT id, role, name, text, created_at
|
"SELECT id, role, name, text, created_at FROM talk_history WHERE chat_id = %s AND user_id = %s ORDER BY id ASC",
|
||||||
FROM talk_history
|
(chat_id, user_id),
|
||||||
WHERE chat_id = ? AND user_id = ?
|
)
|
||||||
ORDER BY id ASC
|
return cur.fetchall()
|
||||||
""",
|
|
||||||
(chat_id, user_id),
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
def _latest_reply_stats(chat_id: int) -> tuple[float, int]:
|
def _latest_reply_stats(chat_id: int) -> tuple[float, int]:
|
||||||
with _connect_db() as conn:
|
with get_conn() as conn:
|
||||||
last_assistant = conn.execute(
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
"""
|
cur.execute(
|
||||||
SELECT id, created_at
|
"SELECT id, created_at FROM chat_history WHERE chat_id = %s AND role = 'assistant' ORDER BY id DESC LIMIT 1",
|
||||||
FROM chat_history
|
|
||||||
WHERE chat_id = ? AND role = 'assistant'
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(chat_id,),
|
(chat_id,),
|
||||||
).fetchone()
|
)
|
||||||
|
last_assistant = cur.fetchone()
|
||||||
if not last_assistant:
|
if not last_assistant:
|
||||||
return 0.0, 10_000
|
return 0.0, 10_000
|
||||||
user_messages_since_reply = conn.execute(
|
cnt_cur = conn.cursor()
|
||||||
"""
|
cnt_cur.execute(
|
||||||
SELECT COUNT(*)
|
"SELECT COUNT(*) FROM chat_history WHERE chat_id = %s AND role = 'user' AND id > %s",
|
||||||
FROM chat_history
|
|
||||||
WHERE chat_id = ? AND role = 'user' AND id > ?
|
|
||||||
""",
|
|
||||||
(chat_id, last_assistant["id"]),
|
(chat_id, last_assistant["id"]),
|
||||||
).fetchone()[0]
|
)
|
||||||
|
user_messages_since_reply = cnt_cur.fetchone()[0]
|
||||||
return float(last_assistant["created_at"] or 0.0), int(user_messages_since_reply)
|
return float(last_assistant["created_at"] or 0.0), int(user_messages_since_reply)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -501,23 +444,23 @@ def _store_summary_and_prune(
|
||||||
last_row_id: int,
|
last_row_id: int,
|
||||||
user_id: int | None = None,
|
user_id: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
with _connect_db() as conn:
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
_set_state_from_conn(conn, chat_id, "summary", summary)
|
_set_state_from_conn(conn, chat_id, "summary", summary)
|
||||||
conn.execute(
|
cur.execute(
|
||||||
"DELETE FROM chat_history WHERE chat_id = ? AND id <= ?",
|
"DELETE FROM chat_history WHERE chat_id = %s AND id <= %s",
|
||||||
(chat_id, last_row_id),
|
(chat_id, last_row_id),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_set_talk_state_from_conn(conn, chat_id, user_id, "summary", summary)
|
_set_talk_state_from_conn(conn, chat_id, user_id, "summary", summary)
|
||||||
conn.execute(
|
cur.execute(
|
||||||
"DELETE FROM talk_history WHERE chat_id = ? AND user_id = ? AND id <= ?",
|
"DELETE FROM talk_history WHERE chat_id = %s AND user_id = %s AND id <= %s",
|
||||||
(chat_id, user_id, last_row_id),
|
(chat_id, user_id, last_row_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _format_row_for_llm(row: sqlite3.Row) -> dict[str, str]:
|
def _format_row_for_llm(row) -> dict[str, str]:
|
||||||
if row["role"] == "user":
|
if row["role"] == "user":
|
||||||
return {
|
return {
|
||||||
"role": "user",
|
"role": "user",
|
||||||
|
|
@ -903,12 +846,13 @@ async def handle_photo_message(message: Message) -> bool:
|
||||||
is_image_document = bool(message.document and (message.document.mime_type or "").startswith("image/"))
|
is_image_document = bool(message.document and (message.document.mime_type or "").startswith("image/"))
|
||||||
if not (message.photo or is_image_document) or not message.from_user or message.from_user.is_bot:
|
if not (message.photo or is_image_document) or not message.from_user or message.from_user.is_bot:
|
||||||
return False
|
return False
|
||||||
if message.caption and message.caption.startswith("/"):
|
watch_match = WATCH_COMMAND_RE.match((message.caption or "").strip())
|
||||||
|
if not watch_match:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
chat_id = message.chat.id
|
chat_id = message.chat.id
|
||||||
user_name = message.from_user.first_name or "кент"
|
user_name = message.from_user.first_name or "кент"
|
||||||
caption = _clean_text(message.caption or "")
|
caption = _clean_text(watch_match.group(1) or "")
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Received image message for analysis. chat_id=%s user=%s has_photo=%s has_image_document=%s caption=%s",
|
"Received image message for analysis. chat_id=%s user=%s has_photo=%s has_image_document=%s caption=%s",
|
||||||
|
|
|
||||||
106
DEPLOYMENT.md
Normal file
106
DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
# Инструкция по развертыванию бота
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
|
### 1. Клонирование и подготовка
|
||||||
|
```bash
|
||||||
|
git clone <репозиторий>
|
||||||
|
cd bot_tg
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Настройка переменных окружения
|
||||||
|
Создайте файл `.env` с необходимыми переменными:
|
||||||
|
```env
|
||||||
|
BOT_TOKEN=your_telegram_bot_token
|
||||||
|
LLAMA_API_URL=http://your-llama-server:8080 # опционально
|
||||||
|
ODDS_API_KEY=your_odds_api_key # опционально для ставок
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Запуск через Docker Compose
|
||||||
|
```bash
|
||||||
|
# Сборка и запуск
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
# Просмотр логов
|
||||||
|
docker compose logs -f bot
|
||||||
|
|
||||||
|
# Остановка
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
bot_tg/
|
||||||
|
├── main.py # основной файл бота
|
||||||
|
├── games/
|
||||||
|
│ ├── economy.py # экономическая система
|
||||||
|
│ ├── casino.py # казино
|
||||||
|
│ ├── betting.py # ставки на спорт
|
||||||
|
│ └── ...
|
||||||
|
├── AI/ # ИИ-модули
|
||||||
|
├── maket/ # макеты для мемов
|
||||||
|
├── db/ # базы данных (создаются автоматически)
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yaml
|
||||||
|
├── requirements.txt
|
||||||
|
└── .env # переменные окружения
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🗄️ Базы данных
|
||||||
|
|
||||||
|
Автоматически создаются в папке `./db`:
|
||||||
|
- `penis_stats.sqlite3` - статистика игры /penis
|
||||||
|
- `chat_history.sqlite3` - история для ИИ-общения
|
||||||
|
- `polychaetsi_stats.json` - статистика слова "получается"
|
||||||
|
- `bets.sqlite3` - ставки на спорт
|
||||||
|
- `economy.sqlite3` - экономическая система (ЦБ, вклады, кредиты)
|
||||||
|
|
||||||
|
## 🏛️ Экономическая система
|
||||||
|
|
||||||
|
**Основная валюта:** сантиметры (см)
|
||||||
|
|
||||||
|
**Команды экономики:**
|
||||||
|
- `/balance` - баланс и статистика
|
||||||
|
- `/deposit [сумма] [дни]` - открыть вклад
|
||||||
|
- `/loan [сумма] [дни]` - взять кредит
|
||||||
|
- `/transfer @username [сумма]` - перевести деньги
|
||||||
|
- `/cb_stats` - статистика Центрального Банка
|
||||||
|
- `/central_bank` - детальная статистика ЦБ
|
||||||
|
- `/cb_rules` - правила работы ЦБ
|
||||||
|
|
||||||
|
**Центральный Банк:** децентрализованная система с капиталом 1000 см
|
||||||
|
|
||||||
|
## 🐛 Поиск проблем
|
||||||
|
|
||||||
|
### Проверка логов
|
||||||
|
```bash
|
||||||
|
docker compose logs bot
|
||||||
|
```
|
||||||
|
|
||||||
|
### Перезапуск бота
|
||||||
|
```bash
|
||||||
|
docker compose restart bot
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проверка статуса контейнера
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ Важные моменты
|
||||||
|
|
||||||
|
1. **Все базы данных хранятся в `./db`** - эта папка монтируется в контейнер
|
||||||
|
2. **Бот работает в часовом поясе Europe/Moscow**
|
||||||
|
3. **Центральный Банк полностью автоматический** - никакого ручного управления
|
||||||
|
4. **Ежедневные выплаты за активность** - в 00:00 по МСК
|
||||||
|
5. **Налоги 13%** - автоматически поступают в ЦБ
|
||||||
|
|
||||||
|
## 🔧 Первоначальная настройка
|
||||||
|
|
||||||
|
1. Получите токен бота у @BotFather
|
||||||
|
2. Добавьте бота в чат (если нужен)
|
||||||
|
3. Настройте `.env` файл
|
||||||
|
4. Запустите через `docker compose up --build -d`
|
||||||
|
|
||||||
|
Бот готов к работе! 🎉
|
||||||
44
README.md
44
README.md
|
|
@ -37,11 +37,12 @@ docker compose down
|
||||||
Для ставок нужен `ODDS_API_KEY` (бесплатно на https://the-odds-api.com).
|
Для ставок нужен `ODDS_API_KEY` (бесплатно на https://the-odds-api.com).
|
||||||
|
|
||||||
Контейнер хранит состояние в `./db`:
|
Контейнер хранит состояние в `./db`:
|
||||||
- `penis_stats.sqlite3`
|
- `penis_stats.sqlite3` — статистика игры /penis
|
||||||
- `chat_history.sqlite3`
|
- `chat_history.sqlite3` — история для ИИ-общения
|
||||||
- `polychaetsi_stats.json`
|
- `polychaetsi_stats.json` — статистика слова "получается"
|
||||||
- `bets.sqlite3`
|
- `bets.sqlite3` — ставки на спорт
|
||||||
- `nude_history.json`
|
- `economy.sqlite3` — экономическая система (балансы, вклады, кредиты)
|
||||||
|
- `nude_history.json` — история запросов /nude
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
|
|
@ -63,6 +64,39 @@ docker compose down
|
||||||
- `/mybets` — мои активные ставки.
|
- `/mybets` — мои активные ставки.
|
||||||
- `/svodka` — сводка СВО.
|
- `/svodka` — сводка СВО.
|
||||||
|
|
||||||
|
## Экономика ZOV OS 💰
|
||||||
|
|
||||||
|
Основная валюта — сантиметры (см), получаемые из игры `/penis`.
|
||||||
|
|
||||||
|
**Команды экономики:**
|
||||||
|
- `/balance` — показать баланс и статистику
|
||||||
|
- `/deposit <сумма> <дни>` — открыть вклад по ставке ЦБ РФ
|
||||||
|
- `/loan <сумма> <дни>` — взять кредит под 15% годовых
|
||||||
|
- `/transfer @username <сумма>` — перевести деньги (лимит 20% от капитала)
|
||||||
|
- `/cb_stats` — статистика Центрального Банка
|
||||||
|
- `/central_bank` — детальная статистика ЦБ
|
||||||
|
- `/cb_rules` — правила работы ЦБ
|
||||||
|
- `/vanomasa` — полное обнуление всех балансов (админам)
|
||||||
|
|
||||||
|
**Фичи экономики:**
|
||||||
|
- Вклады по ставке ЦБ РФ (7.5% годовых)
|
||||||
|
- Кредиты под 15% годовых
|
||||||
|
- Налог 13% с ежедневного дохода
|
||||||
|
- Автоматическое регулирование инфляции
|
||||||
|
- Фоновый сбор налогов каждый час
|
||||||
|
- Заработок на сообщениях: 0.01 см за каждое сообщение
|
||||||
|
- Ежедневные выплаты в 00:00 по МСК за активность
|
||||||
|
|
||||||
|
**Центральный Банк ZOV OS 🏛️ (децентрализованный):**
|
||||||
|
- Учредительный капитал: 1000 см
|
||||||
|
- Полностью автоматическая работа без вмешательства
|
||||||
|
- Все налоги автоматически поступают в ЦБ
|
||||||
|
- Алгоритмическое регулирование инфляции
|
||||||
|
- Автоматическая эмиссия при дефляции
|
||||||
|
- Автоматическая скупка денег при инфляции
|
||||||
|
- Прозрачная статистика для всех пользователей
|
||||||
|
- Никакого ручного управления - система работает по правилам
|
||||||
|
|
||||||
## Макеты и шрифт
|
## Макеты и шрифт
|
||||||
|
|
||||||
- Шрифт `impact.ttf` лежит в корне.
|
- Шрифт `impact.ttf` лежит в корне.
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,9 @@ from zoneinfo import ZoneInfo
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
FONT_PATH = BASE_DIR / "impact.ttf"
|
FONT_PATH = BASE_DIR / "impact.ttf"
|
||||||
DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow")
|
DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow")
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
|
||||||
|
|
||||||
# --- Настройки /penis ---
|
# --- Настройки /penis ---
|
||||||
PENIS_DB_PATH = "/db/penis_stats.sqlite3"
|
|
||||||
CHAT_HISTORY_DB_PATH = "/db/chat_history.sqlite3"
|
|
||||||
PENIS_START_LENGTH = 10.0
|
PENIS_START_LENGTH = 10.0
|
||||||
PENIS_MIN_DELTA = 0.1
|
PENIS_MIN_DELTA = 0.1
|
||||||
PENIS_MAX_DELTA = 7.0
|
PENIS_MAX_DELTA = 7.0
|
||||||
|
|
@ -34,6 +33,7 @@ E621_API_KEY = os.getenv("E621_API_KEY", "")
|
||||||
# --- Ставки на матчи ---
|
# --- Ставки на матчи ---
|
||||||
ODDS_API_KEY = os.getenv("ODDS_API_KEY", "")
|
ODDS_API_KEY = os.getenv("ODDS_API_KEY", "")
|
||||||
ODDS_API_BASE = "https://api.the-odds-api.com/v4"
|
ODDS_API_BASE = "https://api.the-odds-api.com/v4"
|
||||||
|
BET_MAX_AMOUNT = 3.0
|
||||||
ODDS_SPORTS = {
|
ODDS_SPORTS = {
|
||||||
"epl": "soccer_epl",
|
"epl": "soccer_epl",
|
||||||
"ucl": "soccer_uefa_champs_league",
|
"ucl": "soccer_uefa_champs_league",
|
||||||
|
|
@ -43,8 +43,6 @@ ODDS_SPORTS = {
|
||||||
"ligue1": "soccer_france_ligue_one",
|
"ligue1": "soccer_france_ligue_one",
|
||||||
"europa": "soccer_uefa_europa_league",
|
"europa": "soccer_uefa_europa_league",
|
||||||
}
|
}
|
||||||
BET_MAX_AMOUNT = 3.0
|
|
||||||
BET_DB_PATH = "/db/bets.sqlite3"
|
|
||||||
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
|
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
|
||||||
|
|
||||||
# --- Nude ---
|
# --- Nude ---
|
||||||
|
|
|
||||||
35
db.py
Normal file
35
db.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
import psycopg2.pool
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
|
||||||
|
|
||||||
|
_pool: psycopg2.pool.ThreadedConnectionPool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_pool() -> psycopg2.pool.ThreadedConnectionPool:
|
||||||
|
global _pool
|
||||||
|
if _pool is None:
|
||||||
|
_pool = psycopg2.pool.ThreadedConnectionPool(2, 10, dsn=DATABASE_URL)
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_conn() -> Generator[psycopg2.extensions.connection, None, None]:
|
||||||
|
pool = _get_pool()
|
||||||
|
conn = pool.getconn()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
pool.putconn(conn)
|
||||||
38
docker-compose.webapp-test.yml
Normal file
38
docker-compose.webapp-test.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
services:
|
||||||
|
webapp-api-test:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.api
|
||||||
|
container_name: fenyabot-webapp-api-test
|
||||||
|
volumes:
|
||||||
|
- ./db:/db
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
PENIS_DB_PATH: /db/penis_stats.sqlite3
|
||||||
|
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
|
||||||
|
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
||||||
|
BET_DB_PATH: /db/bets.sqlite3
|
||||||
|
API_PORT: "8080"
|
||||||
|
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
|
||||||
|
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
|
||||||
|
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
|
||||||
|
ports:
|
||||||
|
- "18080:8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ngrok-test:
|
||||||
|
image: ngrok/ngrok:latest
|
||||||
|
container_name: fenyabot-ngrok-test
|
||||||
|
depends_on:
|
||||||
|
- webapp-api-test
|
||||||
|
environment:
|
||||||
|
NGROK_AUTHTOKEN: ${NGROK_AUTHTOKEN}
|
||||||
|
command:
|
||||||
|
- http
|
||||||
|
- webapp-api-test:8080
|
||||||
|
- --log=stdout
|
||||||
|
ports:
|
||||||
|
- "4041:4040"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
@ -1,4 +1,19 @@
|
||||||
services:
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: botdb
|
||||||
|
POSTGRES_USER: bot
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-botpass}
|
||||||
|
volumes:
|
||||||
|
- ./db/postgres:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U bot -d botdb"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|
@ -8,13 +23,15 @@ services:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
PENIS_DB_PATH: /db/penis_stats.sqlite3
|
DATABASE_URL: postgresql://bot:${POSTGRES_PASSWORD:-botpass}@postgres:5432/botdb
|
||||||
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
|
|
||||||
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
||||||
BET_DB_PATH: /db/bets.sqlite3
|
NUDE_HISTORY_PATH: /db/nude_history.json
|
||||||
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
|
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
|
||||||
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
|
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
|
||||||
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
|
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
init: true
|
init: true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|
@ -28,14 +45,16 @@ services:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
PENIS_DB_PATH: /db/penis_stats.sqlite3
|
DATABASE_URL: postgresql://bot:${POSTGRES_PASSWORD:-botpass}@postgres:5432/botdb
|
||||||
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
|
|
||||||
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
||||||
BET_DB_PATH: /db/bets.sqlite3
|
NUDE_HISTORY_PATH: /db/nude_history.json
|
||||||
API_PORT: "8080"
|
API_PORT: "8080"
|
||||||
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
|
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
|
||||||
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
|
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
|
||||||
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
|
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
webapp-frontend:
|
webapp-frontend:
|
||||||
|
|
@ -58,5 +77,6 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
postgres_data:
|
||||||
certbot_etc:
|
certbot_etc:
|
||||||
certbot_www:
|
certbot_www:
|
||||||
|
|
|
||||||
208
games/betting.py
208
games/betting.py
|
|
@ -1,13 +1,14 @@
|
||||||
import time as _time
|
|
||||||
import sqlite3
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
import time as _time
|
||||||
from pathlib import Path
|
from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
from db import get_conn
|
||||||
from games.casino import get_user_length, update_user_length
|
from games.casino import get_user_length, update_user_length
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -17,18 +18,16 @@ _sports_cache: tuple[float, list] | None = None
|
||||||
_last_viewed_sport_by_user: dict[int, str] = {}
|
_last_viewed_sport_by_user: dict[int, str] = {}
|
||||||
_DRAW_NAMES = {"draw", "tie", "ничья"}
|
_DRAW_NAMES = {"draw", "tie", "ничья"}
|
||||||
|
|
||||||
# Сколько дней хранить историю ставок
|
|
||||||
BET_HISTORY_DAYS = 30
|
BET_HISTORY_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
def _init_bets_db() -> None:
|
def _init_bets_db() -> None:
|
||||||
Path(config.BET_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
with get_conn() as conn:
|
||||||
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
cur = conn.cursor()
|
||||||
conn.execute(
|
cur.execute('''
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS bets (
|
CREATE TABLE IF NOT EXISTS bets (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id SERIAL PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
match_id TEXT NOT NULL,
|
match_id TEXT NOT NULL,
|
||||||
sport TEXT NOT NULL,
|
sport TEXT NOT NULL,
|
||||||
home_team TEXT NOT NULL,
|
home_team TEXT NOT NULL,
|
||||||
|
|
@ -38,25 +37,15 @@ def _init_bets_db() -> None:
|
||||||
odds REAL NOT NULL,
|
odds REAL NOT NULL,
|
||||||
status TEXT DEFAULT 'pending',
|
status TEXT DEFAULT 'pending',
|
||||||
payout REAL DEFAULT 0,
|
payout REAL DEFAULT 0,
|
||||||
created_ts INTEGER,
|
created_ts BIGINT,
|
||||||
resolved_ts INTEGER
|
resolved_ts BIGINT
|
||||||
)
|
)
|
||||||
"""
|
''')
|
||||||
)
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_user ON bets(user_id, status)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_user ON bets(user_id, status)")
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_match ON bets(match_id, status)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_match ON bets(match_id, status)")
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_created ON bets(created_ts)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_created ON bets(created_ts)")
|
cur.execute("ALTER TABLE bets ADD COLUMN IF NOT EXISTS payout REAL DEFAULT 0")
|
||||||
|
cur.execute("ALTER TABLE bets ADD COLUMN IF NOT EXISTS resolved_ts BIGINT")
|
||||||
# Миграция: добавить столбцы если их нет
|
|
||||||
cursor = conn.execute("PRAGMA table_info(bets)")
|
|
||||||
columns = {row[1] for row in cursor.fetchall()}
|
|
||||||
|
|
||||||
if "payout" not in columns:
|
|
||||||
conn.execute("ALTER TABLE bets ADD COLUMN payout REAL DEFAULT 0")
|
|
||||||
if "resolved_ts" not in columns:
|
|
||||||
conn.execute("ALTER TABLE bets ADD COLUMN resolved_ts INTEGER")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -79,7 +68,6 @@ def get_user_sport_context(user_id: int) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_available_sports() -> list[dict]:
|
async def _fetch_available_sports() -> list[dict]:
|
||||||
"""Получить список доступных видов спорта из API."""
|
|
||||||
global _sports_cache
|
global _sports_cache
|
||||||
now = _time.time()
|
now = _time.time()
|
||||||
if _sports_cache and (now - _sports_cache[0]) < 3600:
|
if _sports_cache and (now - _sports_cache[0]) < 3600:
|
||||||
|
|
@ -287,11 +275,9 @@ async def get_formatted_matches(sport_alias: str | None) -> str:
|
||||||
sport_key = config.ODDS_SPORTS[sport_alias]
|
sport_key = config.ODDS_SPORTS[sport_alias]
|
||||||
matches = await fetch_matches(sport_key)
|
matches = await fetch_matches(sport_key)
|
||||||
if not matches:
|
if not matches:
|
||||||
# Попробуем проверить, существует ли вообще этот спорт
|
|
||||||
sports = await _fetch_available_sports()
|
sports = await _fetch_available_sports()
|
||||||
valid_keys = {s.get("key") for s in sports}
|
valid_keys = {s.get("key") for s in sports}
|
||||||
if sport_key not in valid_keys:
|
if sport_key not in valid_keys:
|
||||||
# Ищем похожий ключ
|
|
||||||
suggestions = [s for s in sports if sport_alias in s.get("key", "").lower()
|
suggestions = [s for s in sports if sport_alias in s.get("key", "").lower()
|
||||||
or sport_alias in s.get("title", "").lower()]
|
or sport_alias in s.get("title", "").lower()]
|
||||||
if suggestions:
|
if suggestions:
|
||||||
|
|
@ -304,10 +290,7 @@ async def get_formatted_matches(sport_alias: str | None) -> str:
|
||||||
f"Похожие виды спорта:\n{suggest_text}\n\n"
|
f"Похожие виды спорта:\n{suggest_text}\n\n"
|
||||||
f"Обнови sport keys в config.py"
|
f"Обнови sport keys в config.py"
|
||||||
)
|
)
|
||||||
return (
|
return f"⚠️ Ключ {sport_key} не найден в API. Проверь ODDS_SPORTS в config.py"
|
||||||
f"⚠️ Ключ {sport_key} не найден в API. "
|
|
||||||
f"Проверь ODDS_SPORTS в config.py"
|
|
||||||
)
|
|
||||||
return format_matches(matches, sport_alias, _get_sport_label(sport_alias))
|
return format_matches(matches, sport_alias, _get_sport_label(sport_alias))
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
|
|
@ -335,16 +318,12 @@ def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: f
|
||||||
current = get_user_length(user_id)
|
current = get_user_length(user_id)
|
||||||
if current is None:
|
if current is None:
|
||||||
return "Сначала заведи счёт через /penis, братуха."
|
return "Сначала заведи счёт через /penis, братуха."
|
||||||
|
|
||||||
if current <= 0:
|
if current <= 0:
|
||||||
return "🚫 С кредитом ставки не принимаем."
|
return "🚫 С кредитом ставки не принимаем."
|
||||||
|
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
return "Ставка должна быть больше нуля."
|
return "Ставка должна быть больше нуля."
|
||||||
|
|
||||||
if amount > config.BET_MAX_AMOUNT:
|
if amount > config.BET_MAX_AMOUNT:
|
||||||
return f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
|
return f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
|
||||||
|
|
||||||
if amount > current:
|
if amount > current:
|
||||||
return f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
|
return f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
|
||||||
|
|
||||||
|
|
@ -358,16 +337,18 @@ def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: f
|
||||||
|
|
||||||
now_ts = int(_time.time())
|
now_ts = int(_time.time())
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
with get_conn() as conn:
|
||||||
conn.execute(
|
cur = conn.cursor()
|
||||||
"""INSERT INTO bets (user_id, match_id, sport, home_team, away_team,
|
cur.execute('''
|
||||||
chosen_team, amount, odds, status, payout, created_ts)
|
INSERT INTO bets
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)""",
|
(user_id, match_id, sport, home_team, away_team,
|
||||||
(user_id, match["id"], sport, match["home"], match["away"],
|
chosen_team, amount, odds, status, payout, created_ts)
|
||||||
chosen_team, amount, team_odds, now_ts),
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', 0, %s)
|
||||||
)
|
''', (
|
||||||
conn.commit()
|
user_id, match["id"], sport, match["home"], match["away"],
|
||||||
except sqlite3.Error:
|
chosen_team, amount, team_odds, now_ts,
|
||||||
|
))
|
||||||
|
except psycopg2.Error:
|
||||||
logger.exception("Failed to place bet")
|
logger.exception("Failed to place bet")
|
||||||
update_user_length(user_id, amount)
|
update_user_length(user_id, amount)
|
||||||
return "Ошибка БД."
|
return "Ошибка БД."
|
||||||
|
|
@ -385,13 +366,15 @@ def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: f
|
||||||
|
|
||||||
def get_user_bets(user_id: int) -> str:
|
def get_user_bets(user_id: int) -> str:
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
with get_conn() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
rows = conn.execute(
|
cur.execute('''
|
||||||
"SELECT * FROM bets WHERE user_id = ? AND status = 'pending' ORDER BY created_ts DESC LIMIT 10",
|
SELECT * FROM bets
|
||||||
(user_id,),
|
WHERE user_id = %s AND status = 'pending'
|
||||||
).fetchall()
|
ORDER BY created_ts DESC LIMIT 10
|
||||||
except sqlite3.Error:
|
''', (user_id,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
except psycopg2.Error:
|
||||||
return "Ошибка БД."
|
return "Ошибка БД."
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
|
|
@ -413,34 +396,26 @@ def get_user_bets(user_id: int) -> str:
|
||||||
|
|
||||||
|
|
||||||
def get_user_bet_history(user_id: int) -> str:
|
def get_user_bet_history(user_id: int) -> str:
|
||||||
"""Полная история ставок за последний месяц."""
|
cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
|
||||||
cutoff_ts = int((_time.time()) - BET_HISTORY_DAYS * 86400)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
with get_conn() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
rows = conn.execute(
|
cur.execute('''
|
||||||
"""SELECT * FROM bets
|
SELECT * FROM bets
|
||||||
WHERE user_id = ? AND created_ts >= ?
|
WHERE user_id = %s AND created_ts >= %s
|
||||||
ORDER BY created_ts DESC
|
ORDER BY created_ts DESC LIMIT 50
|
||||||
LIMIT 50""",
|
''', (user_id, cutoff_ts))
|
||||||
(user_id, cutoff_ts),
|
rows = cur.fetchall()
|
||||||
).fetchall()
|
except psycopg2.Error:
|
||||||
except sqlite3.Error:
|
|
||||||
logger.exception("Failed to get bet history")
|
logger.exception("Failed to get bet history")
|
||||||
return "Ошибка БД."
|
return "Ошибка БД."
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
return "📋 За последний месяц ставок не было."
|
return "📋 За последний месяц ставок не было."
|
||||||
|
|
||||||
# Считаем статистику
|
total_bet = total_won = total_lost = 0.0
|
||||||
total_bet = 0.0
|
wins = losses = pending = 0
|
||||||
total_won = 0.0
|
|
||||||
total_lost = 0.0
|
|
||||||
wins = 0
|
|
||||||
losses = 0
|
|
||||||
pending = 0
|
|
||||||
|
|
||||||
lines = [f"📋 История ставок за {BET_HISTORY_DAYS} дней:\n"]
|
lines = [f"📋 История ставок за {BET_HISTORY_DAYS} дней:\n"]
|
||||||
|
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
|
@ -453,7 +428,6 @@ def get_user_bet_history(user_id: int) -> str:
|
||||||
|
|
||||||
if status == "won":
|
if status == "won":
|
||||||
wins += 1
|
wins += 1
|
||||||
# Для старых ставок payout может быть 0, пересчитываем
|
|
||||||
actual_payout = payout_raw if payout_raw > 0 else round(amount * odds_val, 1)
|
actual_payout = payout_raw if payout_raw > 0 else round(amount * odds_val, 1)
|
||||||
total_won += actual_payout
|
total_won += actual_payout
|
||||||
status_icon = "✅"
|
status_icon = "✅"
|
||||||
|
|
@ -466,8 +440,7 @@ def get_user_bet_history(user_id: int) -> str:
|
||||||
else:
|
else:
|
||||||
pending += 1
|
pending += 1
|
||||||
status_icon = "⏳"
|
status_icon = "⏳"
|
||||||
potential = round(amount * odds_val, 1)
|
result_text = f"ожидание → {round(amount * odds_val, 1):.1f} см"
|
||||||
result_text = f"ожидание → {potential:.1f} см"
|
|
||||||
|
|
||||||
created = ""
|
created = ""
|
||||||
if r["created_ts"]:
|
if r["created_ts"]:
|
||||||
|
|
@ -480,10 +453,8 @@ def get_user_bet_history(user_id: int) -> str:
|
||||||
f" 📅 {created}"
|
f" 📅 {created}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Итоговая статистика
|
|
||||||
net = total_won - total_lost
|
net = total_won - total_lost
|
||||||
net_sign = "+" if net >= 0 else ""
|
net_sign = "+" if net >= 0 else ""
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("━━━ 📊 СТАТИСТИКА ━━━")
|
lines.append("━━━ 📊 СТАТИСТИКА ━━━")
|
||||||
lines.append(f"🎰 Всего ставок: {len(rows)}")
|
lines.append(f"🎰 Всего ставок: {len(rows)}")
|
||||||
|
|
@ -495,29 +466,26 @@ def get_user_bet_history(user_id: int) -> str:
|
||||||
lines.append(f"🏆 Выиграно: +{total_won:.1f} см")
|
lines.append(f"🏆 Выиграно: +{total_won:.1f} см")
|
||||||
lines.append(f"💸 Проиграно: -{total_lost:.1f} см")
|
lines.append(f"💸 Проиграно: -{total_lost:.1f} см")
|
||||||
lines.append(f"📈 Итого: {net_sign}{net:.1f} см")
|
lines.append(f"📈 Итого: {net_sign}{net:.1f} см")
|
||||||
|
|
||||||
if wins + losses > 0:
|
if wins + losses > 0:
|
||||||
winrate = wins / (wins + losses) * 100
|
lines.append(f"📊 Винрейт: {wins / (wins + losses) * 100:.0f}%")
|
||||||
lines.append(f"📊 Винрейт: {winrate:.0f}%")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def cleanup_old_bets() -> int:
|
def cleanup_old_bets() -> int:
|
||||||
"""Удалить завершённые ставки старше BET_HISTORY_DAYS дней."""
|
|
||||||
cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
|
cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
with get_conn() as conn:
|
||||||
cursor = conn.execute(
|
cur = conn.cursor()
|
||||||
"DELETE FROM bets WHERE status != 'pending' AND created_ts < ?",
|
cur.execute(
|
||||||
|
"DELETE FROM bets WHERE status != 'pending' AND created_ts < %s",
|
||||||
(cutoff_ts,),
|
(cutoff_ts,),
|
||||||
)
|
)
|
||||||
conn.commit()
|
deleted = cur.rowcount
|
||||||
deleted = cursor.rowcount
|
|
||||||
if deleted > 0:
|
if deleted > 0:
|
||||||
logger.info(f"Cleaned up {deleted} old bets")
|
logger.info(f"Cleaned up {deleted} old bets")
|
||||||
return deleted
|
return deleted
|
||||||
except sqlite3.Error:
|
except psycopg2.Error:
|
||||||
logger.exception("Failed to cleanup old bets")
|
logger.exception("Failed to cleanup old bets")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
@ -525,12 +493,11 @@ def cleanup_old_bets() -> int:
|
||||||
async def settle_bets() -> list[str]:
|
async def settle_bets() -> list[str]:
|
||||||
notifications = []
|
notifications = []
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
with get_conn() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
pending = conn.execute(
|
cur.execute("SELECT DISTINCT match_id, sport FROM bets WHERE status = 'pending'")
|
||||||
"SELECT DISTINCT match_id, sport FROM bets WHERE status = 'pending'"
|
pending = cur.fetchall()
|
||||||
).fetchall()
|
except psycopg2.Error:
|
||||||
except sqlite3.Error:
|
|
||||||
return notifications
|
return notifications
|
||||||
|
|
||||||
if not pending:
|
if not pending:
|
||||||
|
|
@ -557,29 +524,22 @@ async def settle_bets() -> list[str]:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for event in scores_data:
|
for event in scores_data:
|
||||||
if event.get("id") != match_id:
|
if event.get("id") != match_id or not event.get("completed"):
|
||||||
continue
|
|
||||||
if not event.get("completed"):
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
scores = event.get("scores")
|
scores = event.get("scores")
|
||||||
if not scores:
|
if not scores:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
score_values = [(s.get("name", ""), int(s.get("score", 0))) for s in scores]
|
||||||
winner = None
|
winner = None
|
||||||
max_score = -1
|
|
||||||
is_draw = False
|
is_draw = False
|
||||||
|
|
||||||
score_values = []
|
|
||||||
for s in scores:
|
|
||||||
score_val = int(s.get("score", 0))
|
|
||||||
score_values.append((s.get("name", ""), score_val))
|
|
||||||
|
|
||||||
if len(score_values) >= 2 and score_values[0][1] == score_values[1][1]:
|
if len(score_values) >= 2 and score_values[0][1] == score_values[1][1]:
|
||||||
is_draw = True
|
is_draw = True
|
||||||
# Для ничьи ищем "Draw" среди ставок
|
|
||||||
winner = "Draw"
|
winner = "Draw"
|
||||||
else:
|
else:
|
||||||
|
max_score = -1
|
||||||
for name, score_val in score_values:
|
for name, score_val in score_values:
|
||||||
if score_val > max_score:
|
if score_val > max_score:
|
||||||
max_score = score_val
|
max_score = score_val
|
||||||
|
|
@ -589,19 +549,18 @@ async def settle_bets() -> list[str]:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
with get_conn() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
bets = conn.execute(
|
cur.execute(
|
||||||
"SELECT * FROM bets WHERE match_id = ? AND status = 'pending'",
|
"SELECT * FROM bets WHERE match_id = %s AND status = 'pending'",
|
||||||
(match_id,),
|
(match_id,),
|
||||||
).fetchall()
|
)
|
||||||
|
bets = cur.fetchall()
|
||||||
|
|
||||||
|
wcur = conn.cursor()
|
||||||
for bet in bets:
|
for bet in bets:
|
||||||
chosen = bet["chosen_team"]
|
chosen = bet["chosen_team"]
|
||||||
bet_won = False
|
|
||||||
|
|
||||||
if is_draw:
|
if is_draw:
|
||||||
# Ставка на ничью выигрывает
|
|
||||||
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
|
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
|
||||||
else:
|
else:
|
||||||
bet_won = chosen == winner
|
bet_won = chosen == winner
|
||||||
|
|
@ -609,8 +568,8 @@ async def settle_bets() -> list[str]:
|
||||||
if bet_won:
|
if bet_won:
|
||||||
winnings = round(bet["amount"] * bet["odds"], 1)
|
winnings = round(bet["amount"] * bet["odds"], 1)
|
||||||
update_user_length(bet["user_id"], winnings)
|
update_user_length(bet["user_id"], winnings)
|
||||||
conn.execute(
|
wcur.execute(
|
||||||
"UPDATE bets SET status = 'won', payout = ?, resolved_ts = ? WHERE id = ?",
|
"UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s",
|
||||||
(winnings, now_ts, bet["id"]),
|
(winnings, now_ts, bet["id"]),
|
||||||
)
|
)
|
||||||
notifications.append(
|
notifications.append(
|
||||||
|
|
@ -618,26 +577,22 @@ async def settle_bets() -> list[str]:
|
||||||
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
|
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
conn.execute(
|
wcur.execute(
|
||||||
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = ? WHERE id = ?",
|
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s",
|
||||||
(now_ts, bet["id"]),
|
(now_ts, bet["id"]),
|
||||||
)
|
)
|
||||||
notifications.append(
|
notifications.append(
|
||||||
f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см "
|
f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см "
|
||||||
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
|
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
|
||||||
)
|
)
|
||||||
conn.commit()
|
except psycopg2.Error:
|
||||||
except sqlite3.Error:
|
|
||||||
logger.exception("Failed to settle bets")
|
logger.exception("Failed to settle bets")
|
||||||
|
|
||||||
# Очистка старых ставок
|
|
||||||
cleanup_old_bets()
|
cleanup_old_bets()
|
||||||
|
|
||||||
return notifications
|
return notifications
|
||||||
|
|
||||||
|
|
||||||
async def debug_sports() -> str:
|
async def debug_sports() -> str:
|
||||||
"""Диагностика: показать доступные виды спорта из API."""
|
|
||||||
if not config.ODDS_API_KEY:
|
if not config.ODDS_API_KEY:
|
||||||
return "⚠️ ODDS_API_KEY не задан."
|
return "⚠️ ODDS_API_KEY не задан."
|
||||||
|
|
||||||
|
|
@ -645,17 +600,14 @@ async def debug_sports() -> str:
|
||||||
if not sports:
|
if not sports:
|
||||||
return "❌ Не удалось получить список спортов от API."
|
return "❌ Не удалось получить список спортов от API."
|
||||||
|
|
||||||
# Показываем текущие ключи и проверяем их
|
|
||||||
current_keys = set(config.ODDS_SPORTS.values())
|
current_keys = set(config.ODDS_SPORTS.values())
|
||||||
valid_keys = {s.get("key") for s in sports}
|
valid_keys = {s.get("key") for s in sports}
|
||||||
|
|
||||||
lines = ["🔍 Диагностика sport keys:\n"]
|
lines = ["🔍 Диагностика sport keys:\n"]
|
||||||
|
|
||||||
for alias, key in config.ODDS_SPORTS.items():
|
for alias, key in config.ODDS_SPORTS.items():
|
||||||
status = "✅" if key in valid_keys else "❌ НЕ НАЙДЕН"
|
status = "✅" if key in valid_keys else "❌ НЕ НАЙДЕН"
|
||||||
lines.append(f" {alias} → {key} {status}")
|
lines.append(f" {alias} → {key} {status}")
|
||||||
|
|
||||||
# Показать доступные esports и football
|
|
||||||
lines.append("\n📋 Доступные eSports и футбол:")
|
lines.append("\n📋 Доступные eSports и футбол:")
|
||||||
for s in sports:
|
for s in sports:
|
||||||
key = s.get("key", "")
|
key = s.get("key", "")
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
import random
|
|
||||||
import sqlite3
|
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
|
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
from db import get_conn
|
||||||
|
from .economy import economy
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Символы слотов и их веса (чем выше вес — тем чаще выпадает)
|
|
||||||
SLOT_SYMBOLS = ["🍒", "🍋", "🔔", "💎", "7️⃣", "🍀"]
|
SLOT_SYMBOLS = ["🍒", "🍋", "🔔", "💎", "7️⃣", "🍀"]
|
||||||
SLOT_WEIGHTS = [30, 25, 20, 12, 8, 5] # 🍒 чаще всего, 🍀 реже всего
|
SLOT_WEIGHTS = [30, 25, 20, 12, 8, 5]
|
||||||
|
|
||||||
# Множители для выигрышей: {кол-во совпадений: (мин, макс)}
|
|
||||||
MULTIPLIERS = {
|
MULTIPLIERS = {
|
||||||
2: (1.2, 1.8), # две одинаковых
|
2: (1.2, 1.8),
|
||||||
3: (2.0, 3.0), # три одинаковых (джекпот, редко)
|
3: (2.0, 3.0),
|
||||||
}
|
}
|
||||||
|
|
||||||
CASINO_WIN_CHANCE = 0.35
|
CASINO_WIN_CHANCE = 0.35
|
||||||
|
|
@ -30,69 +31,53 @@ def spin_slots() -> tuple[list[str], int]:
|
||||||
counts = {}
|
counts = {}
|
||||||
for s in reels:
|
for s in reels:
|
||||||
counts[s] = counts.get(s, 0) + 1
|
counts[s] = counts.get(s, 0) + 1
|
||||||
max_match = max(counts.values())
|
return reels, max(counts.values())
|
||||||
return reels, max_match
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_length(user_id: int) -> float | None:
|
def get_user_length(user_id: int) -> float | None:
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute(
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
"SELECT length FROM penis_stats WHERE user_id = ?",
|
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
|
||||||
(user_id,),
|
row = cur.fetchone()
|
||||||
).fetchone()
|
return float(row['balance']) if row else None
|
||||||
return float(row[0]) if row else None
|
except Exception:
|
||||||
except sqlite3.Error:
|
logger.exception("casino: failed to get balance")
|
||||||
logger.exception("casino: failed to get length")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def update_user_length(user_id: int, delta: float) -> float | None:
|
def update_user_length(user_id: int, delta: float) -> float | None:
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute(
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
"SELECT length FROM penis_stats WHERE user_id = ?",
|
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
|
||||||
(user_id,),
|
row = cur.fetchone()
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
new_length = round(float(row[0]) + delta, 1)
|
new_balance = round(float(row['balance']) + delta, 1)
|
||||||
# Не даём уйти ниже 0 при проигрыше
|
if delta < 0 and new_balance < 0:
|
||||||
if delta < 0 and new_length < 0:
|
new_balance = 0.0
|
||||||
new_length = 0.0
|
wcur = conn.cursor()
|
||||||
conn.execute(
|
wcur.execute(
|
||||||
"UPDATE penis_stats SET length = ? WHERE user_id = ?",
|
'UPDATE user_balances SET balance = %s WHERE user_id = %s',
|
||||||
(new_length, user_id),
|
(new_balance, user_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
return new_balance
|
||||||
return new_length
|
except Exception:
|
||||||
except sqlite3.Error:
|
logger.exception("casino: failed to update balance")
|
||||||
logger.exception("casino: failed to update length")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def play_casino(user_id: int, bet: float) -> str:
|
def play_casino(user_id: int, bet: float) -> str:
|
||||||
current = get_user_length(user_id)
|
current = economy.get_user_balance(user_id)
|
||||||
if current is None:
|
|
||||||
return "Ты ещё ни разу не крутил /penis, братуха. Сначала заведи счёт."
|
|
||||||
|
|
||||||
# Проверяем отрицательный баланс
|
|
||||||
if current <= 0:
|
|
||||||
return "🚫 С кредитом в казино не впускают, братуха. Иди наращивай через /penis."
|
|
||||||
|
|
||||||
# Проверяем максимум ставки
|
|
||||||
if bet > config.CASINO_MAX_BET:
|
if bet > config.CASINO_MAX_BET:
|
||||||
return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай."
|
return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай."
|
||||||
|
|
||||||
if bet <= 0:
|
if bet <= 0:
|
||||||
return "Ставка должна быть больше нуля, фраер."
|
return "Ставка должна быть больше нуля, фраер."
|
||||||
|
|
||||||
# Проверяем хватает ли
|
|
||||||
if bet > current:
|
if bet > current:
|
||||||
return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер."
|
return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер."
|
||||||
|
|
||||||
# Крутим
|
|
||||||
reels, matches = spin_slots()
|
reels, matches = spin_slots()
|
||||||
slots_display = " | ".join(reels)
|
slots_display = " | ".join(reels)
|
||||||
|
|
||||||
|
|
@ -100,9 +85,8 @@ def play_casino(user_id: int, bet: float) -> str:
|
||||||
min_mult, max_mult = MULTIPLIERS[matches]
|
min_mult, max_mult = MULTIPLIERS[matches]
|
||||||
multiplier = round(random.uniform(min_mult, max_mult), 1)
|
multiplier = round(random.uniform(min_mult, max_mult), 1)
|
||||||
winnings = round(bet * multiplier, 1)
|
winnings = round(bet * multiplier, 1)
|
||||||
new_length = update_user_length(user_id, winnings)
|
economy.update_balance(user_id, winnings, 'casino_win', f'Выигрыш в казино x{multiplier}')
|
||||||
if new_length is None:
|
new_balance = economy.get_user_balance(user_id)
|
||||||
return "Ошибка БД. Попробуй позже."
|
|
||||||
|
|
||||||
if matches == 3:
|
if matches == 3:
|
||||||
return (
|
return (
|
||||||
|
|
@ -110,22 +94,21 @@ def play_casino(user_id: int, bet: float) -> str:
|
||||||
f"🎉 ДЖЕКПОТ!!! Три одинаковых!\n"
|
f"🎉 ДЖЕКПОТ!!! Три одинаковых!\n"
|
||||||
f"Множитель: x{multiplier}\n"
|
f"Множитель: x{multiplier}\n"
|
||||||
f"Выигрыш: +{winnings:.1f} см\n"
|
f"Выигрыш: +{winnings:.1f} см\n"
|
||||||
f"📏 Текущий размер: {new_length:.1f} см"
|
f"💰 Баланс: {new_balance:.1f} см"
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
f"🎰 {slots_display}\n\n"
|
f"🎰 {slots_display}\n\n"
|
||||||
f"✅ Выигрыш! Две совпали.\n"
|
f"✅ Выигрыш! Две совпали.\n"
|
||||||
f"Множитель: x{multiplier}\n"
|
f"Множитель: x{multiplier}\n"
|
||||||
f"Выигрыш: +{winnings:.1f} см\n"
|
f"Выигрыш: +{winnings:.1f} см\n"
|
||||||
f"📏 Текущий размер: {new_length:.1f} см"
|
f"💰 Баланс: {new_balance:.1f} см"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
new_length = update_user_length(user_id, -bet)
|
economy.update_balance(user_id, -bet, 'casino_loss', f'Проигрыш в казино -{bet} см')
|
||||||
if new_length is None:
|
new_balance = economy.get_user_balance(user_id)
|
||||||
return "Ошибка БД. Попробуй позже."
|
|
||||||
return (
|
return (
|
||||||
f"🎰 {slots_display}\n\n"
|
f"🎰 {slots_display}\n\n"
|
||||||
f"❌ Мимо, братуха.\n"
|
f"❌ Мимо, братуха.\n"
|
||||||
f"Проигрыш: -{bet:.1f} см\n"
|
f"Проигрыш: -{bet:.1f} см\n"
|
||||||
f"📏 Текущий размер: {new_length:.1f} см"
|
f"💰 Баланс: {new_balance:.1f} см"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
669
games/economy.py
Normal file
669
games/economy.py
Normal file
|
|
@ -0,0 +1,669 @@
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
|
from db import get_conn
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EconomyManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.init_db()
|
||||||
|
|
||||||
|
def init_db(self):
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS user_balances (
|
||||||
|
user_id BIGINT PRIMARY KEY,
|
||||||
|
balance REAL DEFAULT 0.0,
|
||||||
|
daily_income REAL DEFAULT 0.0,
|
||||||
|
last_daily_reset DATE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
cur.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS deposits (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT,
|
||||||
|
amount REAL,
|
||||||
|
interest_rate REAL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
matures_at TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
cur.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS loans (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT,
|
||||||
|
amount REAL,
|
||||||
|
interest_rate REAL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
due_at TIMESTAMP,
|
||||||
|
is_repaid BOOLEAN DEFAULT FALSE
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
cur.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS transactions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
from_user_id BIGINT,
|
||||||
|
to_user_id BIGINT,
|
||||||
|
amount REAL,
|
||||||
|
transaction_type TEXT,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
cur.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS taxes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT,
|
||||||
|
amount REAL,
|
||||||
|
tax_date DATE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
cur.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS economy_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
cur.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_activity (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT,
|
||||||
|
activity_date DATE,
|
||||||
|
message_count INTEGER DEFAULT 0,
|
||||||
|
last_activity_time TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (user_id, activity_date)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
cur.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS central_bank (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
|
capital REAL DEFAULT 1000.0,
|
||||||
|
total_taxes_collected REAL DEFAULT 0.0,
|
||||||
|
total_inflation_adjustments REAL DEFAULT 0.0,
|
||||||
|
total_emissions REAL DEFAULT 0.0,
|
||||||
|
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
cur.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS penis_stats (
|
||||||
|
user_id BIGINT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
last_used_ts BIGINT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
default_settings = {
|
||||||
|
'central_bank_rate': '7.5',
|
||||||
|
'deposit_min_amount': '10.0',
|
||||||
|
'deposit_min_days': '7',
|
||||||
|
'loan_max_amount': '100.0',
|
||||||
|
'loan_interest_rate': '15.0',
|
||||||
|
'tax_rate': '13.0',
|
||||||
|
'transfer_limit_percent': '20.0',
|
||||||
|
'inflation_rate': '5.0',
|
||||||
|
'vanomasa_cooldown': '30',
|
||||||
|
'message_reward_rate': '0.01',
|
||||||
|
'last_activity_payout': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value in default_settings.items():
|
||||||
|
cur.execute('''
|
||||||
|
INSERT INTO economy_settings (key, value) VALUES (%s, %s)
|
||||||
|
ON CONFLICT (key) DO NOTHING
|
||||||
|
''', (key, value))
|
||||||
|
|
||||||
|
cur.execute('''
|
||||||
|
INSERT INTO central_bank (id, capital) VALUES (1, 1000.0)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
''')
|
||||||
|
|
||||||
|
def get_user_balance(self, user_id: int) -> float:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
|
||||||
|
result = cur.fetchone()
|
||||||
|
return result['balance'] if result else 0.0
|
||||||
|
|
||||||
|
def update_balance(self, user_id: int, amount: float, transaction_type: str, description: str = ""):
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('''
|
||||||
|
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
||||||
|
VALUES (%s, %s, 0.0, CURRENT_DATE)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
|
||||||
|
''', (user_id, amount, amount))
|
||||||
|
|
||||||
|
cur.execute('''
|
||||||
|
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
''', (user_id, user_id, amount, transaction_type, description))
|
||||||
|
|
||||||
|
logger.info(f"Balance updated for user {user_id}: +{amount} ({transaction_type})")
|
||||||
|
|
||||||
|
def transfer_money(self, from_user_id: int, to_user_id: int, amount: float, description: str = "") -> bool:
|
||||||
|
if amount <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
from_balance = self.get_user_balance(from_user_id)
|
||||||
|
if amount > from_balance * 0.2 or from_balance < amount:
|
||||||
|
return False
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
'UPDATE user_balances SET balance = balance - %s WHERE user_id = %s',
|
||||||
|
(amount, from_user_id),
|
||||||
|
)
|
||||||
|
cur.execute('''
|
||||||
|
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
||||||
|
VALUES (%s, %s, 0.0, CURRENT_DATE)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
|
||||||
|
''', (to_user_id, amount, amount))
|
||||||
|
cur.execute('''
|
||||||
|
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
|
||||||
|
VALUES (%s, %s, %s, 'transfer', %s)
|
||||||
|
''', (from_user_id, to_user_id, amount, description))
|
||||||
|
|
||||||
|
logger.info(f"Transfer: {amount} from user {from_user_id} to user {to_user_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def create_deposit(self, user_id: int, amount: float, days: int) -> bool:
|
||||||
|
if amount <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
balance = self.get_user_balance(user_id)
|
||||||
|
min_deposit = float(self.get_setting('deposit_min_amount'))
|
||||||
|
|
||||||
|
if amount < min_deposit or balance < amount:
|
||||||
|
return False
|
||||||
|
|
||||||
|
central_bank_rate = float(self.get_setting('central_bank_rate'))
|
||||||
|
matures_at = datetime.now() + timedelta(days=days)
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
'UPDATE user_balances SET balance = balance - %s WHERE user_id = %s',
|
||||||
|
(amount, user_id),
|
||||||
|
)
|
||||||
|
cur.execute('''
|
||||||
|
UPDATE central_bank SET capital = capital + %s, last_updated = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
|
''', (amount,))
|
||||||
|
cur.execute('''
|
||||||
|
INSERT INTO deposits (user_id, amount, interest_rate, matures_at)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
''', (user_id, amount, central_bank_rate, matures_at))
|
||||||
|
|
||||||
|
logger.info(f"Deposit created: user {user_id}, amount {amount}, rate {central_bank_rate}%")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def create_loan(self, user_id: int, amount: float, days: int) -> bool:
|
||||||
|
if amount <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
max_loan = float(self.get_setting('loan_max_amount'))
|
||||||
|
loan_rate = float(self.get_setting('loan_interest_rate'))
|
||||||
|
|
||||||
|
if amount > max_loan:
|
||||||
|
return False
|
||||||
|
|
||||||
|
due_at = datetime.now() + timedelta(days=days)
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('''
|
||||||
|
UPDATE central_bank SET capital = capital - %s, last_updated = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
|
''', (amount,))
|
||||||
|
cur.execute('''
|
||||||
|
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
||||||
|
VALUES (%s, %s, 0.0, CURRENT_DATE)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
|
||||||
|
''', (user_id, amount, amount))
|
||||||
|
cur.execute('''
|
||||||
|
INSERT INTO loans (user_id, amount, interest_rate, due_at)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
''', (user_id, amount, loan_rate, due_at))
|
||||||
|
|
||||||
|
logger.info(f"Loan created: user {user_id}, amount {amount}, rate {loan_rate}%")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def repay_loan(self, user_id: int, loan_id: int) -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute(
|
||||||
|
'SELECT amount, interest_rate, is_repaid FROM loans WHERE id = %s AND user_id = %s',
|
||||||
|
(loan_id, user_id),
|
||||||
|
)
|
||||||
|
loan = cur.fetchone()
|
||||||
|
|
||||||
|
if not loan or loan['is_repaid']:
|
||||||
|
return False
|
||||||
|
|
||||||
|
total_repayment = loan['amount'] * (1 + loan['interest_rate'] / 100)
|
||||||
|
|
||||||
|
balance = self.get_user_balance(user_id)
|
||||||
|
if balance < total_repayment:
|
||||||
|
return False
|
||||||
|
|
||||||
|
wcur = conn.cursor()
|
||||||
|
wcur.execute(
|
||||||
|
'UPDATE user_balances SET balance = balance - %s WHERE user_id = %s',
|
||||||
|
(total_repayment, user_id),
|
||||||
|
)
|
||||||
|
wcur.execute('''
|
||||||
|
UPDATE central_bank SET capital = capital + %s, last_updated = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
|
''', (total_repayment,))
|
||||||
|
wcur.execute('UPDATE loans SET is_repaid = TRUE WHERE id = %s', (loan_id,))
|
||||||
|
|
||||||
|
logger.info(f"Loan repaid: user {user_id}, loan {loan_id}, amount {total_repayment}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def collect_daily_taxes(self):
|
||||||
|
tax_rate = float(self.get_setting('tax_rate')) / 100
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute('''
|
||||||
|
SELECT user_id, daily_income FROM user_balances
|
||||||
|
WHERE daily_income > 0 AND (last_daily_reset != CURRENT_DATE OR last_daily_reset IS NULL)
|
||||||
|
''')
|
||||||
|
users = cur.fetchall()
|
||||||
|
|
||||||
|
total_taxes_collected = 0.0
|
||||||
|
wcur = conn.cursor()
|
||||||
|
|
||||||
|
for row in users:
|
||||||
|
user_id = row['user_id']
|
||||||
|
daily_income = row['daily_income']
|
||||||
|
tax_amount = daily_income * tax_rate
|
||||||
|
if tax_amount <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
wcur.execute('''
|
||||||
|
UPDATE user_balances
|
||||||
|
SET balance = balance - %s, daily_income = 0, last_daily_reset = CURRENT_DATE
|
||||||
|
WHERE user_id = %s
|
||||||
|
''', (tax_amount, user_id))
|
||||||
|
wcur.execute(
|
||||||
|
'INSERT INTO taxes (user_id, amount, tax_date) VALUES (%s, %s, CURRENT_DATE)',
|
||||||
|
(user_id, tax_amount),
|
||||||
|
)
|
||||||
|
total_taxes_collected += tax_amount
|
||||||
|
|
||||||
|
if total_taxes_collected > 0:
|
||||||
|
wcur.execute('''
|
||||||
|
UPDATE central_bank
|
||||||
|
SET capital = capital + %s, total_taxes_collected = total_taxes_collected + %s
|
||||||
|
WHERE id = 1
|
||||||
|
''', (total_taxes_collected, total_taxes_collected))
|
||||||
|
|
||||||
|
logger.info(f"Daily taxes collected: {total_taxes_collected:.2f} cm from {len(users)} users")
|
||||||
|
|
||||||
|
def count_message(self, user_id: int):
|
||||||
|
today = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('''
|
||||||
|
INSERT INTO daily_activity (user_id, activity_date, message_count, last_activity_time)
|
||||||
|
VALUES (%s, %s, 1, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (user_id, activity_date) DO UPDATE SET
|
||||||
|
message_count = daily_activity.message_count + 1,
|
||||||
|
last_activity_time = CURRENT_TIMESTAMP
|
||||||
|
''', (user_id, today))
|
||||||
|
|
||||||
|
def pay_daily_activity_rewards(self) -> int:
|
||||||
|
reward_rate = float(self.get_setting('message_reward_rate'))
|
||||||
|
last_payout = self.get_setting('last_activity_payout')
|
||||||
|
today = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
if last_payout == today:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute('''
|
||||||
|
SELECT user_id, message_count FROM daily_activity
|
||||||
|
WHERE activity_date = %s AND message_count > 0
|
||||||
|
''', (yesterday,))
|
||||||
|
users = cur.fetchall()
|
||||||
|
|
||||||
|
total_paid = 0.0
|
||||||
|
wcur = conn.cursor()
|
||||||
|
|
||||||
|
for row in users:
|
||||||
|
user_id = row['user_id']
|
||||||
|
message_count = row['message_count']
|
||||||
|
reward = message_count * reward_rate
|
||||||
|
if reward <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
wcur.execute('''
|
||||||
|
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
||||||
|
VALUES (%s, %s, 0.0, CURRENT_DATE)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
balance = user_balances.balance + %s,
|
||||||
|
daily_income = user_balances.daily_income + %s
|
||||||
|
''', (user_id, reward, reward, reward))
|
||||||
|
wcur.execute('''
|
||||||
|
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
|
||||||
|
VALUES (%s, %s, %s, 'activity_reward', %s)
|
||||||
|
''', (user_id, user_id, reward, f'Вознаграждение за {message_count} сообщений'))
|
||||||
|
total_paid += reward
|
||||||
|
|
||||||
|
wcur.execute(
|
||||||
|
'UPDATE economy_settings SET value = %s WHERE key = %s',
|
||||||
|
(today, 'last_activity_payout'),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Activity rewards paid: {total_paid:.2f} cm to {len(users)} users")
|
||||||
|
return len(users)
|
||||||
|
|
||||||
|
def get_central_bank_stats(self) -> Dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute('''
|
||||||
|
SELECT capital, total_taxes_collected, total_inflation_adjustments, total_emissions
|
||||||
|
FROM central_bank WHERE id = 1
|
||||||
|
''')
|
||||||
|
result = cur.fetchone()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return {
|
||||||
|
'capital': result['capital'],
|
||||||
|
'total_taxes_collected': result['total_taxes_collected'],
|
||||||
|
'total_inflation_adjustments': result['total_inflation_adjustments'],
|
||||||
|
'total_emissions': result['total_emissions'],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'capital': 1000.0,
|
||||||
|
'total_taxes_collected': 0.0,
|
||||||
|
'total_inflation_adjustments': 0.0,
|
||||||
|
'total_emissions': 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_central_bank(self, capital_change: float, transaction_type: str, description: str = ""):
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('''
|
||||||
|
UPDATE central_bank SET capital = capital + %s, last_updated = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
|
''', (capital_change,))
|
||||||
|
|
||||||
|
if transaction_type == 'tax':
|
||||||
|
cur.execute('''
|
||||||
|
UPDATE central_bank
|
||||||
|
SET total_taxes_collected = total_taxes_collected + %s WHERE id = 1
|
||||||
|
''', (abs(capital_change),))
|
||||||
|
elif transaction_type == 'inflation':
|
||||||
|
cur.execute('''
|
||||||
|
UPDATE central_bank
|
||||||
|
SET total_inflation_adjustments = total_inflation_adjustments + %s WHERE id = 1
|
||||||
|
''', (abs(capital_change),))
|
||||||
|
elif transaction_type == 'emission':
|
||||||
|
cur.execute('''
|
||||||
|
UPDATE central_bank
|
||||||
|
SET total_emissions = total_emissions + %s WHERE id = 1
|
||||||
|
''', (abs(capital_change),))
|
||||||
|
|
||||||
|
logger.info(f"Central bank updated: {capital_change} ({transaction_type})")
|
||||||
|
|
||||||
|
def emit_money(self, amount: float, reason: str = "") -> bool:
|
||||||
|
if self.get_central_bank_stats()['capital'] < amount:
|
||||||
|
return False
|
||||||
|
self.update_central_bank(-amount, 'emission', reason)
|
||||||
|
logger.info(f"Money emitted: {amount} cm ({reason})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def vanomasa(self) -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute("SELECT value FROM economy_settings WHERE key = %s", ('last_vanomasa',))
|
||||||
|
last_vanomasa = cur.fetchone()
|
||||||
|
|
||||||
|
if last_vanomasa and last_vanomasa['value']:
|
||||||
|
try:
|
||||||
|
last_date = datetime.strptime(last_vanomasa['value'], '%Y-%m-%d')
|
||||||
|
cooldown_days = int(self.get_setting('vanomasa_cooldown'))
|
||||||
|
if datetime.now() - last_date < timedelta(days=cooldown_days):
|
||||||
|
return False
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
today = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
wcur = conn.cursor()
|
||||||
|
wcur.execute('UPDATE user_balances SET balance = 0, daily_income = 0')
|
||||||
|
wcur.execute('''
|
||||||
|
INSERT INTO economy_settings (key, value, updated_at) VALUES (%s, %s, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
|
||||||
|
''', ('last_vanomasa', today))
|
||||||
|
|
||||||
|
logger.warning("VANOMASA: All balances reset to zero!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_setting(self, key: str) -> str:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute('SELECT value FROM economy_settings WHERE key = %s', (key,))
|
||||||
|
result = cur.fetchone()
|
||||||
|
return result['value'] if result else '0'
|
||||||
|
|
||||||
|
def update_setting(self, key: str, value: str):
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('''
|
||||||
|
INSERT INTO economy_settings (key, value, updated_at) VALUES (%s, %s, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
|
||||||
|
''', (key, value))
|
||||||
|
|
||||||
|
def get_user_stats(self, user_id: int) -> Dict:
|
||||||
|
today = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
|
||||||
|
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
balance = row['balance'] if row else 0.0
|
||||||
|
|
||||||
|
cur.execute('''
|
||||||
|
SELECT COUNT(*) AS cnt, COALESCE(SUM(amount), 0) AS total
|
||||||
|
FROM deposits WHERE user_id = %s AND is_active = TRUE
|
||||||
|
''', (user_id,))
|
||||||
|
deposits = cur.fetchone()
|
||||||
|
|
||||||
|
cur.execute('''
|
||||||
|
SELECT COUNT(*) AS cnt, COALESCE(SUM(amount), 0) AS total
|
||||||
|
FROM loans WHERE user_id = %s AND is_repaid = FALSE
|
||||||
|
''', (user_id,))
|
||||||
|
loans = cur.fetchone()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
'SELECT message_count FROM daily_activity WHERE user_id = %s AND activity_date = %s',
|
||||||
|
(user_id, today),
|
||||||
|
)
|
||||||
|
today_row = cur.fetchone()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
'SELECT message_count FROM daily_activity WHERE user_id = %s AND activity_date = %s',
|
||||||
|
(user_id, yesterday),
|
||||||
|
)
|
||||||
|
yesterday_row = cur.fetchone()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'balance': balance,
|
||||||
|
'active_deposits_count': deposits['cnt'] or 0,
|
||||||
|
'active_deposits_sum': deposits['total'] or 0.0,
|
||||||
|
'active_loans_count': loans['cnt'] or 0,
|
||||||
|
'active_loans_sum': loans['total'] or 0.0,
|
||||||
|
'today_messages': today_row['message_count'] if today_row else 0,
|
||||||
|
'yesterday_messages': yesterday_row['message_count'] if yesterday_row else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def regulate_inflation(self):
|
||||||
|
inflation_rate = float(self.get_setting('inflation_rate'))
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute('SELECT SUM(balance) AS total_balance, COUNT(*) AS user_count FROM user_balances')
|
||||||
|
result = cur.fetchone()
|
||||||
|
total_supply = result['total_balance'] or 0.0
|
||||||
|
|
||||||
|
cur.execute('SELECT capital FROM central_bank WHERE id = 1')
|
||||||
|
cb_result = cur.fetchone()
|
||||||
|
cb_capital = cb_result['capital'] if cb_result else 1000.0
|
||||||
|
|
||||||
|
if inflation_rate > 10.0 and total_supply > 1000 and cb_capital > 100:
|
||||||
|
buyback_amount = min(total_supply * 0.05, cb_capital * 0.1)
|
||||||
|
self.update_central_bank(-buyback_amount, 'inflation', f'Anti-inflation: {buyback_amount:.2f} cm')
|
||||||
|
new_tax_rate = min(20.0, 13.0 + (inflation_rate - 10.0) * 0.5)
|
||||||
|
self.update_setting('tax_rate', str(new_tax_rate))
|
||||||
|
logger.info(f"Inflation regulation: bought back {buyback_amount:.2f} cm, tax={new_tax_rate}%")
|
||||||
|
|
||||||
|
elif inflation_rate < 2.0 and total_supply < 500 and cb_capital > 200:
|
||||||
|
emission_amount = cb_capital * 0.05
|
||||||
|
self.update_central_bank(-emission_amount, 'emission', f'Stimulus: {emission_amount:.2f} cm')
|
||||||
|
new_tax_rate = max(10.0, 13.0 - (2.0 - inflation_rate) * 1.5)
|
||||||
|
self.update_setting('tax_rate', str(new_tax_rate))
|
||||||
|
logger.info(f"Economic stimulus: emitted {emission_amount:.2f} cm, tax={new_tax_rate}%")
|
||||||
|
|
||||||
|
def play_penis(self, user_id: int, display_name: str, now_ts: int,
|
||||||
|
start_length: float, cooldown_seconds: int,
|
||||||
|
min_delta: float, max_delta: float) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
|
||||||
|
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
current_balance = row['balance'] if row else start_length
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
'SELECT last_used_ts, display_name FROM penis_stats WHERE user_id = %s',
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
ps_row = cur.fetchone()
|
||||||
|
last_used_ts = ps_row['last_used_ts'] if ps_row else None
|
||||||
|
|
||||||
|
if last_used_ts is not None:
|
||||||
|
next_ts = int(last_used_ts) + cooldown_seconds
|
||||||
|
if now_ts < next_ts:
|
||||||
|
if ps_row and ps_row['display_name'] != display_name:
|
||||||
|
wcur = conn.cursor()
|
||||||
|
wcur.execute(
|
||||||
|
'UPDATE penis_stats SET display_name = %s WHERE user_id = %s',
|
||||||
|
(display_name, user_id),
|
||||||
|
)
|
||||||
|
return {'allowed': False, 'left_seconds': next_ts - now_ts}
|
||||||
|
|
||||||
|
delta = round(random.uniform(min_delta, max_delta), 1)
|
||||||
|
sign = random.choice([-1, 1])
|
||||||
|
new_balance = round(max(0.1, current_balance + sign * delta), 1)
|
||||||
|
|
||||||
|
wcur = conn.cursor()
|
||||||
|
wcur.execute('''
|
||||||
|
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
||||||
|
VALUES (%s, %s, 0.0, CURRENT_DATE)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET balance = %s
|
||||||
|
''', (user_id, new_balance, new_balance))
|
||||||
|
|
||||||
|
sign_label = f'+{delta:.1f}' if sign > 0 else f'-{delta:.1f}'
|
||||||
|
wcur.execute('''
|
||||||
|
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
|
||||||
|
VALUES (%s, %s, %s, 'penis_game', %s)
|
||||||
|
''', (user_id, user_id, sign * delta, f'Игра /penis: {sign_label} см'))
|
||||||
|
|
||||||
|
if ps_row is None:
|
||||||
|
wcur.execute(
|
||||||
|
'INSERT INTO penis_stats (user_id, display_name, last_used_ts) VALUES (%s, %s, %s)',
|
||||||
|
(user_id, display_name, now_ts),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
wcur.execute(
|
||||||
|
'UPDATE penis_stats SET display_name = %s, last_used_ts = %s WHERE user_id = %s',
|
||||||
|
(display_name, now_ts, user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {'allowed': True, 'new_balance': new_balance, 'delta': delta, 'sign': sign}
|
||||||
|
|
||||||
|
def get_penis_top(self, limit: int) -> list:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute('''
|
||||||
|
SELECT ub.user_id, ps.display_name, ub.balance
|
||||||
|
FROM user_balances ub
|
||||||
|
INNER JOIN penis_stats ps ON ub.user_id = ps.user_id
|
||||||
|
ORDER BY ub.balance DESC, ub.user_id ASC
|
||||||
|
LIMIT %s
|
||||||
|
''', (limit,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [{'user_id': r['user_id'], 'display_name': r['display_name'], 'balance': r['balance']}
|
||||||
|
for r in rows]
|
||||||
|
|
||||||
|
def process_matured_deposits(self) -> int:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute('''
|
||||||
|
SELECT id, user_id, amount, interest_rate
|
||||||
|
FROM deposits
|
||||||
|
WHERE is_active = TRUE AND matures_at <= NOW()
|
||||||
|
''')
|
||||||
|
matured = cur.fetchall()
|
||||||
|
|
||||||
|
paid = 0
|
||||||
|
wcur = conn.cursor()
|
||||||
|
for row in matured:
|
||||||
|
dep_id = row['id']
|
||||||
|
user_id = row['user_id']
|
||||||
|
amount = row['amount']
|
||||||
|
rate = row['interest_rate']
|
||||||
|
payout = round(amount * (1 + rate / 100 * 1 / 365 * 30), 2)
|
||||||
|
|
||||||
|
wcur.execute('''
|
||||||
|
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
||||||
|
VALUES (%s, %s, 0.0, CURRENT_DATE)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
|
||||||
|
''', (user_id, payout, payout))
|
||||||
|
wcur.execute('''
|
||||||
|
UPDATE central_bank SET capital = capital - %s, last_updated = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
|
''', (payout,))
|
||||||
|
wcur.execute('UPDATE deposits SET is_active = FALSE WHERE id = %s', (dep_id,))
|
||||||
|
wcur.execute('''
|
||||||
|
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
|
||||||
|
VALUES (0, %s, %s, 'deposit_payout', %s)
|
||||||
|
''', (user_id, payout, f'Выплата вклада #{dep_id}: {amount:.1f} + проценты = {payout:.2f} см'))
|
||||||
|
paid += 1
|
||||||
|
|
||||||
|
if paid:
|
||||||
|
logger.info(f"Matured deposits paid out: {paid}")
|
||||||
|
return paid
|
||||||
|
|
||||||
|
|
||||||
|
economy = EconomyManager()
|
||||||
|
|
@ -18,6 +18,7 @@ CAPTIONS = [
|
||||||
"Шо ты блять псюнчик решил свой подергать?",
|
"Шо ты блять псюнчик решил свой подергать?",
|
||||||
"Решил подрочить сука?",
|
"Решил подрочить сука?",
|
||||||
"гнойный писюн у тебя кнч, да",
|
"гнойный писюн у тебя кнч, да",
|
||||||
|
"Ух нихуя, это ШПАЧИХА!?",
|
||||||
]
|
]
|
||||||
|
|
||||||
LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts"
|
LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts"
|
||||||
|
|
|
||||||
412
main.py
412
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 dataclasses import dataclass
|
||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
@ -39,6 +39,7 @@ from games.betting import (
|
||||||
resolve_match_outcome,
|
resolve_match_outcome,
|
||||||
settle_bets,
|
settle_bets,
|
||||||
)
|
)
|
||||||
|
from games.economy import economy
|
||||||
from zparser import get_military_data
|
from zparser import get_military_data
|
||||||
import config
|
import config
|
||||||
|
|
||||||
|
|
@ -58,6 +59,12 @@ def _is_supported_image_message(message: Message) -> bool:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _is_watch_image_message(message: Message) -> bool:
|
||||||
|
if not _is_supported_image_message(message):
|
||||||
|
return False
|
||||||
|
caption = (message.caption or "").strip()
|
||||||
|
return bool(re.match(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s|$)", caption, flags=re.IGNORECASE))
|
||||||
|
|
||||||
|
|
||||||
async def handle_unmatched_message_debug(message: Message):
|
async def handle_unmatched_message_debug(message: Message):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -132,23 +139,6 @@ class LessonSlot:
|
||||||
|
|
||||||
# --- Инициализация и вспомогательные функции ---
|
# --- Инициализация и вспомогательные функции ---
|
||||||
|
|
||||||
def init_penis_db() -> None:
|
|
||||||
"""Инициализация БД для миниигры."""
|
|
||||||
Path(config.PENIS_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS penis_stats (
|
|
||||||
user_id INTEGER PRIMARY KEY,
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
length REAL NOT NULL,
|
|
||||||
last_used_ts INTEGER
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_penis_stats_length ON penis_stats(length DESC)")
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def init_polychaetsi_storage() -> None:
|
def init_polychaetsi_storage() -> None:
|
||||||
"""Инициализация json-хранилища статистики слова 'получается'."""
|
"""Инициализация json-хранилища статистики слова 'получается'."""
|
||||||
path = config.POLYCHAETSI_STATS_PATH
|
path = config.POLYCHAETSI_STATS_PATH
|
||||||
|
|
@ -723,61 +713,29 @@ def play_penis(user_id: int, user_name: str | None = None, now: datetime | None
|
||||||
display_name = user_name.strip() if user_name and user_name.strip() else f"user_{user_id}"
|
display_name = user_name.strip() if user_name and user_name.strip() else f"user_{user_id}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
result = economy.play_penis(
|
||||||
conn.row_factory = sqlite3.Row
|
user_id, display_name, now_ts,
|
||||||
row = conn.execute(
|
config.PENIS_START_LENGTH, config.PENIS_COOLDOWN_SECONDS,
|
||||||
"SELECT length, last_used_ts, display_name FROM penis_stats WHERE user_id = ?",
|
config.PENIS_MIN_DELTA, config.PENIS_MAX_DELTA,
|
||||||
(user_id,),
|
)
|
||||||
).fetchone()
|
except Exception:
|
||||||
|
|
||||||
if row is None:
|
|
||||||
current_length = config.PENIS_START_LENGTH
|
|
||||||
last_used_ts = None
|
|
||||||
else:
|
|
||||||
current_length = float(row["length"])
|
|
||||||
last_used_ts = row["last_used_ts"]
|
|
||||||
stored_name = row["display_name"]
|
|
||||||
if not user_name and isinstance(stored_name, str) and stored_name.strip():
|
|
||||||
display_name = stored_name.strip()
|
|
||||||
|
|
||||||
if last_used_ts is not None:
|
|
||||||
next_ts = int(last_used_ts) + config.PENIS_COOLDOWN_SECONDS
|
|
||||||
if now_ts < next_ts:
|
|
||||||
if row is not None and row["display_name"] != display_name:
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE penis_stats SET display_name = ? WHERE user_id = ?",
|
|
||||||
(display_name, user_id),
|
|
||||||
)
|
|
||||||
left_seconds = next_ts - now_ts
|
|
||||||
hours = left_seconds // 3600
|
|
||||||
minutes = (left_seconds % 3600) // 60
|
|
||||||
conn.commit()
|
|
||||||
return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин."
|
|
||||||
|
|
||||||
delta = round(random.uniform(config.PENIS_MIN_DELTA, config.PENIS_MAX_DELTA), 1)
|
|
||||||
sign = random.choice([-1, 1])
|
|
||||||
new_length = round(max(0.1, current_length + sign * delta), 1)
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO penis_stats(user_id, display_name, length, last_used_ts) VALUES(?, ?, ?, ?)",
|
|
||||||
(user_id, display_name, new_length, now_ts),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE penis_stats SET display_name = ?, length = ?, last_used_ts = ? WHERE user_id = ?",
|
|
||||||
(display_name, new_length, now_ts, user_id),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
except sqlite3.Error:
|
|
||||||
logger.exception("Failed to process /penis")
|
logger.exception("Failed to process /penis")
|
||||||
return False, "Ошибка БД. Попробуй позже."
|
return False, "Ошибка БД. Попробуй позже."
|
||||||
|
|
||||||
|
if not result['allowed']:
|
||||||
|
left = result['left_seconds']
|
||||||
|
hours = left // 3600
|
||||||
|
minutes = (left % 3600) // 60
|
||||||
|
return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин."
|
||||||
|
|
||||||
|
sign = result['sign']
|
||||||
|
delta = result['delta']
|
||||||
|
new_balance = result['new_balance']
|
||||||
sign_text = "+" if sign > 0 else "-"
|
sign_text = "+" if sign > 0 else "-"
|
||||||
return (
|
return (
|
||||||
True,
|
True,
|
||||||
f"📏 Изменение: {sign_text}{delta:.1f} см\n"
|
f"📏 Изменение: {sign_text}{delta:.1f} см\n"
|
||||||
f"Текущая длина: {new_length:.1f} см\n"
|
f"Текущая длина: {new_balance:.1f} см\n"
|
||||||
"Следующая попытка через 24 часа.",
|
"Следующая попытка через 24 часа.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -790,18 +748,8 @@ def format_penis_user_name(user: types.User) -> str:
|
||||||
|
|
||||||
def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str:
|
def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str:
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
rows = economy.get_penis_top(limit)
|
||||||
conn.row_factory = sqlite3.Row
|
except Exception:
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT user_id, display_name, length
|
|
||||||
FROM penis_stats
|
|
||||||
ORDER BY length DESC, user_id ASC
|
|
||||||
LIMIT ?
|
|
||||||
""",
|
|
||||||
(limit,),
|
|
||||||
).fetchall()
|
|
||||||
except sqlite3.Error:
|
|
||||||
logger.exception("Failed to build /top_penis")
|
logger.exception("Failed to build /top_penis")
|
||||||
return "Не удалось загрузить топ. Ошибка БД."
|
return "Не удалось загрузить топ. Ошибка БД."
|
||||||
|
|
||||||
|
|
@ -812,11 +760,10 @@ def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str:
|
||||||
lines = ["🏆 Топ по длине:"]
|
lines = ["🏆 Топ по длине:"]
|
||||||
for index, row in enumerate(rows, start=1):
|
for index, row in enumerate(rows, start=1):
|
||||||
user_id = int(row["user_id"])
|
user_id = int(row["user_id"])
|
||||||
length = float(row["length"])
|
balance = float(row["balance"])
|
||||||
name = row["display_name"] if isinstance(row["display_name"], str) else f"user_{user_id}"
|
name = str(row["display_name"]).strip() or f"user_{user_id}"
|
||||||
name = name.strip() if name.strip() else f"user_{user_id}"
|
|
||||||
prefix = medals.get(index, f"{index}.")
|
prefix = medals.get(index, f"{index}.")
|
||||||
lines.append(f"{prefix} {name} — {length:.1f} см")
|
lines.append(f"{prefix} {name} — {balance:.1f} см")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _load_polychaetsi_stats() -> dict:
|
def _load_polychaetsi_stats() -> dict:
|
||||||
|
|
@ -1195,10 +1142,19 @@ _autoreply_disabled_chats: set[int] = set()
|
||||||
async def handle_keywords(message: Message):
|
async def handle_keywords(message: Message):
|
||||||
if not message.text or message.text.startswith('/'):
|
if not message.text or message.text.startswith('/'):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Считаем сообщение для экономической системы
|
||||||
|
if message.from_user:
|
||||||
|
await asyncio.to_thread(economy.count_message, message.from_user.id)
|
||||||
|
|
||||||
text_lower = message.text.lower()
|
text_lower = message.text.lower()
|
||||||
chat_id = message.chat.id
|
chat_id = message.chat.id
|
||||||
user_name = (message.from_user.first_name or "кент") if message.from_user else "кент"
|
user_name = (message.from_user.first_name or "кент") if message.from_user else "кент"
|
||||||
|
|
||||||
|
# /ebalnik должен глушить все авто-реакции в чате, включая медиа/фото-ветки.
|
||||||
|
if chat_id in _autoreply_disabled_chats:
|
||||||
|
return
|
||||||
|
|
||||||
if any(re.search(rf'\b{word}\b', text_lower) for word in config.KEYWORDS):
|
if any(re.search(rf'\b{word}\b', text_lower) for word in config.KEYWORDS):
|
||||||
audio_path = config.BASE_DIR / "maket" / "Radiohead - Creep.mp3"
|
audio_path = config.BASE_DIR / "maket" / "Radiohead - Creep.mp3"
|
||||||
if audio_path.exists():
|
if audio_path.exists():
|
||||||
|
|
@ -1225,9 +1181,6 @@ async def handle_keywords(message: Message):
|
||||||
# Записываем каждое обычное сообщение в историю чата
|
# Записываем каждое обычное сообщение в историю чата
|
||||||
await asyncio.to_thread(push_message, chat_id, "user", user_name, message.text)
|
await asyncio.to_thread(push_message, chat_id, "user", user_name, message.text)
|
||||||
|
|
||||||
if chat_id in _autoreply_disabled_chats:
|
|
||||||
return
|
|
||||||
|
|
||||||
await handle_chat_message(message, store_message=False, allow_autonomous=True)
|
await handle_chat_message(message, store_message=False, allow_autonomous=True)
|
||||||
|
|
||||||
async def handle_penis_casino_cmd(message: Message):
|
async def handle_penis_casino_cmd(message: Message):
|
||||||
|
|
@ -1246,6 +1199,244 @@ async def handle_penis_casino_cmd(message: Message):
|
||||||
result = await asyncio.to_thread(play_casino, message.from_user.id, bet)
|
result = await asyncio.to_thread(play_casino, message.from_user.id, bet)
|
||||||
await message.reply(result, parse_mode=None)
|
await message.reply(result, parse_mode=None)
|
||||||
|
|
||||||
|
# --- Экономические команды ---
|
||||||
|
|
||||||
|
async def handle_balance_cmd(message: Message):
|
||||||
|
"""Показать баланс пользователя"""
|
||||||
|
if not message.from_user:
|
||||||
|
await message.answer("Команда доступна только пользователям.")
|
||||||
|
return
|
||||||
|
|
||||||
|
balance = economy.get_user_balance(message.from_user.id)
|
||||||
|
stats = await asyncio.to_thread(economy.get_user_stats, message.from_user.id)
|
||||||
|
cb_stats = await asyncio.to_thread(economy.get_central_bank_stats)
|
||||||
|
|
||||||
|
text = f"""💰 **Твой баланс:** {balance:.1f} см
|
||||||
|
|
||||||
|
📊 **Статистика:**
|
||||||
|
• Активные вклады: {stats['active_deposits_count']} шт. ({stats['active_deposits_sum']:.1f} см)
|
||||||
|
• Активные кредиты: {stats['active_loans_count']} шт. ({stats['active_loans_sum']:.1f} см)
|
||||||
|
• Сообщений сегодня: {stats['today_messages']} шт.
|
||||||
|
• Сообщений вчера: {stats['yesterday_messages']} шт.
|
||||||
|
• Ожидаемый доход: {stats['yesterday_messages'] * 0.01:.2f} см
|
||||||
|
|
||||||
|
🏛️ **Центральный Банк:**
|
||||||
|
• Капитал ЦБ: {cb_stats['capital']:.1f} см
|
||||||
|
• Собрано налогов: {cb_stats['total_taxes_collected']:.1f} см
|
||||||
|
|
||||||
|
💡 **Команды экономики:**
|
||||||
|
/balance - показать баланс
|
||||||
|
/deposit [сумма] [дни] - открыть вклад
|
||||||
|
/loans - показать кредиты
|
||||||
|
/loan [сумма] [дни] - взять кредит
|
||||||
|
/repay [ID] - погасить кредит
|
||||||
|
/transfer @username [сумма] - перевести деньги
|
||||||
|
/cb_stats - статистика ЦБ
|
||||||
|
/vanomasa - полное обнуление (для админов)
|
||||||
|
|
||||||
|
💬 **Заработок на сообщениях:**
|
||||||
|
0.01 см за каждое сообщение
|
||||||
|
Выплата в 00:00 по МСК
|
||||||
|
"""
|
||||||
|
await message.answer(text, parse_mode=None)
|
||||||
|
|
||||||
|
async def handle_deposit_cmd(message: Message):
|
||||||
|
"""Открыть вклад"""
|
||||||
|
if not message.from_user:
|
||||||
|
await message.answer("Команда доступна только пользователям.")
|
||||||
|
return
|
||||||
|
|
||||||
|
parts = message.text.split(maxsplit=2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
await message.reply("Формат: /deposit [сумма] [дни]\nПример: /deposit 50 30", parse_mode=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
amount = float(parts[1].replace(",", "."))
|
||||||
|
days = int(parts[2])
|
||||||
|
except ValueError:
|
||||||
|
await message.reply("Сумма должна быть числом, дни - целым числом.", parse_mode=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
if amount <= 0 or days <= 0:
|
||||||
|
await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
success = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days)
|
||||||
|
if success:
|
||||||
|
rate = economy.get_setting('central_bank_rate')
|
||||||
|
await message.reply(f"✅ Вклад открыт!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None)
|
||||||
|
else:
|
||||||
|
await message.reply("❌ Не удалось открыть вклад. Проверь баланс и минимальную сумму.", parse_mode=None)
|
||||||
|
|
||||||
|
async def handle_loans_cmd(message: Message):
|
||||||
|
"""Показать кредиты"""
|
||||||
|
if not message.from_user:
|
||||||
|
await message.answer("Команда доступна только пользователям.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Здесь можно добавить логику показа кредитов пользователя
|
||||||
|
await message.reply("📋 Твои кредиты:\n\n(функция в разработке)", parse_mode=None)
|
||||||
|
|
||||||
|
async def handle_loan_cmd(message: Message):
|
||||||
|
"""Взять кредит"""
|
||||||
|
if not message.from_user:
|
||||||
|
await message.answer("Команда доступна только пользователям.")
|
||||||
|
return
|
||||||
|
|
||||||
|
parts = message.text.split(maxsplit=2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
await message.reply("Формат: /loan [сумма] [дни]\nПример: /loan 50 30", parse_mode=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
amount = float(parts[1].replace(",", "."))
|
||||||
|
days = int(parts[2])
|
||||||
|
except ValueError:
|
||||||
|
await message.reply("Сумма должна быть числом, дни - целым числом.", parse_mode=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
if amount <= 0 or days <= 0:
|
||||||
|
await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
success = await asyncio.to_thread(economy.create_loan, message.from_user.id, amount, days)
|
||||||
|
if success:
|
||||||
|
rate = economy.get_setting('loan_interest_rate')
|
||||||
|
await message.reply(f"✅ Кредит выдан!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None)
|
||||||
|
else:
|
||||||
|
await message.reply("❌ Не удалось взять кредит. Проверь лимиты.", parse_mode=None)
|
||||||
|
|
||||||
|
async def handle_transfer_cmd(message: Message):
|
||||||
|
"""Перевод денег другому пользователю"""
|
||||||
|
if not message.from_user:
|
||||||
|
await message.answer("Команда доступна только пользователям.")
|
||||||
|
return
|
||||||
|
|
||||||
|
parts = message.text.split(maxsplit=2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
await message.reply("Формат: /transfer @username [сумма]\nПример: /transfer @user123 10.5", parse_mode=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
username = parts[1].lstrip('@')
|
||||||
|
try:
|
||||||
|
amount = float(parts[2].replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
await message.reply("Сумма должна быть числом.", parse_mode=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
if amount <= 0:
|
||||||
|
await message.reply("Сумма должна быть положительной.", parse_mode=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Здесь нужно найти user_id по username
|
||||||
|
# Для простоты примера, пока покажем сообщение об ошибке
|
||||||
|
await message.reply("🔍 Поиск пользователя...\n(функция поиска пользователей будет добавлена)", parse_mode=None)
|
||||||
|
|
||||||
|
async def handle_vanomasa_cmd(message: Message):
|
||||||
|
"""Полное обнуление (только для админов)"""
|
||||||
|
if not message.from_user:
|
||||||
|
await message.answer("Команда доступна только пользователям.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Проверка на админа (простая реализация)
|
||||||
|
if message.from_user.id not in [123456789]: # Заменить на реальные ID админов
|
||||||
|
await message.reply("❌ Команда доступна только администраторам!", parse_mode=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
success = await asyncio.to_thread(economy.vanomasa)
|
||||||
|
if success:
|
||||||
|
await message.reply("💥 **ВАНОМАСА!** 💥\n\nВсе балансы обнулены!", parse_mode=None)
|
||||||
|
else:
|
||||||
|
await message.reply("❌ Ваномаса еще не доступна. Подождите.", parse_mode=None)
|
||||||
|
|
||||||
|
async def handle_central_bank_cmd(message: Message):
|
||||||
|
"""Статистика Центрального Банка"""
|
||||||
|
if not message.from_user:
|
||||||
|
await message.answer("Команда доступна только пользователям.")
|
||||||
|
return
|
||||||
|
|
||||||
|
cb_stats = await asyncio.to_thread(economy.get_central_bank_stats)
|
||||||
|
|
||||||
|
text = f"""🏛️ **Центральный Банк ZOV OS** (децентрализованный)
|
||||||
|
|
||||||
|
💰 **Капитал ЦБ:** {cb_stats['capital']:.2f} см
|
||||||
|
📊 **Статистика операций:**
|
||||||
|
• Всего собрано налогов: {cb_stats['total_taxes_collected']:.2f} см
|
||||||
|
• Регулирование инфляции: {cb_stats['total_inflation_adjustments']:.2f} см
|
||||||
|
• Эмиссия денег: {cb_stats['total_emissions']:.2f} см
|
||||||
|
|
||||||
|
🤖 **Автоматическое регулирование:**
|
||||||
|
• ЦБ работает по алгоритмам без вмешательства
|
||||||
|
• Налоги автоматически поступают в ЦБ
|
||||||
|
• Инфляция регулируется автоматически
|
||||||
|
• Эмиссия при дефляции, скупка при инфляции
|
||||||
|
|
||||||
|
💡 **Команды ЦБ:**
|
||||||
|
/central_bank - детальная статистика
|
||||||
|
/cb_stats - краткая статистика
|
||||||
|
/cb_rules - правила работы ЦБ
|
||||||
|
"""
|
||||||
|
await message.answer(text, parse_mode=None)
|
||||||
|
|
||||||
|
async def handle_cb_stats_cmd(message: Message):
|
||||||
|
"""Статистика ЦБ для всех пользователей"""
|
||||||
|
if not message.from_user:
|
||||||
|
await message.answer("Команда доступна только пользователям.")
|
||||||
|
return
|
||||||
|
|
||||||
|
cb_stats = await asyncio.to_thread(economy.get_central_bank_stats)
|
||||||
|
|
||||||
|
text = f"""🏛️ **Центральный Банк ZOV OS** (децентрализованный)
|
||||||
|
|
||||||
|
💰 **Капитал ЦБ:** {cb_stats['capital']:.2f} см
|
||||||
|
📊 **Операции ЦБ:**
|
||||||
|
• Собрано налогов: {cb_stats['total_taxes_collected']:.2f} см
|
||||||
|
• Антиинфляция: {cb_stats['total_inflation_adjustments']:.2f} см
|
||||||
|
• Эмиссия: {cb_stats['total_emissions']:.2f} см
|
||||||
|
|
||||||
|
🔹 ЦБ работает автоматически по алгоритмам
|
||||||
|
🔹 Учредительный капитал: 1000 см
|
||||||
|
🔹 Все налоги поступают в ЦБ
|
||||||
|
🔹 Никакого ручного управления
|
||||||
|
"""
|
||||||
|
await message.answer(text, parse_mode=None)
|
||||||
|
|
||||||
|
async def handle_cb_rules_cmd(message: Message):
|
||||||
|
"""Правила работы Центрального Банка"""
|
||||||
|
if not message.from_user:
|
||||||
|
await message.answer("Команда доступна только пользователям.")
|
||||||
|
return
|
||||||
|
|
||||||
|
text = f"""📖 **Правила Центрального Банка ZOV OS**
|
||||||
|
|
||||||
|
🤖 **Децентрализованная система:**
|
||||||
|
• ЦБ работает полностью автоматически
|
||||||
|
• Никакого ручного вмешательства
|
||||||
|
• Все решения принимаются по алгоритмам
|
||||||
|
|
||||||
|
<EFBFBD> **Сбор налогов:**
|
||||||
|
• 13% налог автоматически поступает в ЦБ
|
||||||
|
• Налоги собираются каждый час
|
||||||
|
• Капитал ЦБ растет за счет налогов
|
||||||
|
|
||||||
|
📈 **Борьба с инфляцией:**
|
||||||
|
• Если инфляция > 10%: ЦБ скупает деньги
|
||||||
|
• Увеличивает налоги до 20% максимум
|
||||||
|
• Скупает до 5% от массы денег
|
||||||
|
|
||||||
|
<EFBFBD> **Стимуляция экономики:**
|
||||||
|
• Если инфляция < 2%: ЦБ проводит эмиссию
|
||||||
|
• Уменьшает налоги до 10% минимум
|
||||||
|
• Эмиссия до 5% от капитала ЦБ
|
||||||
|
|
||||||
|
⚖️ **Баланс системы:**
|
||||||
|
• ЦБ не может обанкротиться (учредительный капитал)
|
||||||
|
• Автоматическая стабилизация экономики
|
||||||
|
• Прозрачная статистика для всех
|
||||||
|
"""
|
||||||
|
await message.answer(text, parse_mode=None)
|
||||||
|
|
||||||
async def handle_gadanie_cmd(message: Message):
|
async def handle_gadanie_cmd(message: Message):
|
||||||
parts = message.text.split(maxsplit=1)
|
parts = message.text.split(maxsplit=1)
|
||||||
if len(parts) < 2 or not parts[1].strip():
|
if len(parts) < 2 or not parts[1].strip():
|
||||||
|
|
@ -1424,7 +1615,6 @@ def main():
|
||||||
token = os.getenv("BOT_TOKEN")
|
token = os.getenv("BOT_TOKEN")
|
||||||
if not token: raise RuntimeError("BOT_TOKEN не задан!")
|
if not token: raise RuntimeError("BOT_TOKEN не задан!")
|
||||||
|
|
||||||
init_penis_db()
|
|
||||||
init_polychaetsi_storage()
|
init_polychaetsi_storage()
|
||||||
bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||||
dp = Dispatcher()
|
dp = Dispatcher()
|
||||||
|
|
@ -1456,7 +1646,20 @@ def main():
|
||||||
dp.message.register(handle_gen_mem, Command("gen_mem"))
|
dp.message.register(handle_gen_mem, Command("gen_mem"))
|
||||||
dp.message.register(handle_uwu_cmd, Command("uwu"))
|
dp.message.register(handle_uwu_cmd, Command("uwu"))
|
||||||
dp.message.register(handle_nude_cmd, Command("nude"))
|
dp.message.register(handle_nude_cmd, Command("nude"))
|
||||||
dp.message.register(handle_photo_message, _is_supported_image_message)
|
dp.message.register(handle_photo_message, _is_watch_image_message)
|
||||||
|
|
||||||
|
# Экономические команды
|
||||||
|
dp.message.register(handle_balance_cmd, Command("balance"))
|
||||||
|
dp.message.register(handle_deposit_cmd, Command("deposit"))
|
||||||
|
dp.message.register(handle_loans_cmd, Command("loans"))
|
||||||
|
dp.message.register(handle_loan_cmd, Command("loan"))
|
||||||
|
dp.message.register(handle_transfer_cmd, Command("transfer"))
|
||||||
|
dp.message.register(handle_vanomasa_cmd, Command("vanomasa"))
|
||||||
|
|
||||||
|
# Команды Центрального Банка (децентрализованные)
|
||||||
|
dp.message.register(handle_central_bank_cmd, Command("central_bank"))
|
||||||
|
dp.message.register(handle_cb_stats_cmd, Command("cb_stats"))
|
||||||
|
dp.message.register(handle_cb_rules_cmd, Command("cb_rules"))
|
||||||
dp.message.register(handle_keywords, F.text)
|
dp.message.register(handle_keywords, F.text)
|
||||||
dp.message.register(handle_unmatched_message_debug)
|
dp.message.register(handle_unmatched_message_debug)
|
||||||
|
|
||||||
|
|
@ -1477,6 +1680,7 @@ def main():
|
||||||
BotCommand(command="fetch", description="alias для /prices"),
|
BotCommand(command="fetch", description="alias для /prices"),
|
||||||
BotCommand(command="zvetok", description="Цветянский бля"),
|
BotCommand(command="zvetok", description="Цветянский бля"),
|
||||||
BotCommand(command="talk", description="Побазарить"),
|
BotCommand(command="talk", description="Побазарить"),
|
||||||
|
BotCommand(command="watch", description="Коммент фото по подписи /watch"),
|
||||||
BotCommand(command="ebalnik", description="вкл/выкл автответов"),
|
BotCommand(command="ebalnik", description="вкл/выкл автответов"),
|
||||||
BotCommand(command="penis_casino", description="казино на размер"),
|
BotCommand(command="penis_casino", description="казино на размер"),
|
||||||
BotCommand(command="gadanie", description="гадание на фене"),
|
BotCommand(command="gadanie", description="гадание на фене"),
|
||||||
|
|
@ -1488,6 +1692,13 @@ def main():
|
||||||
BotCommand(command="svodka", description="СВО: итоги"),
|
BotCommand(command="svodka", description="СВО: итоги"),
|
||||||
BotCommand(command="uwu", description="Случайная картинка с e621"),
|
BotCommand(command="uwu", description="Случайная картинка с e621"),
|
||||||
BotCommand(command="nude", description="Голые женщины из открытых источников"),
|
BotCommand(command="nude", description="Голые женщины из открытых источников"),
|
||||||
|
BotCommand(command="balance", description="💰 баланс и статистика"),
|
||||||
|
BotCommand(command="deposit", description="💎 открыть вклад"),
|
||||||
|
BotCommand(command="loan", description="💵 взять кредит"),
|
||||||
|
BotCommand(command="transfer", description="💸 перевести деньги"),
|
||||||
|
BotCommand(command="cb_stats", description="🏛️ статистика ЦБ"),
|
||||||
|
BotCommand(command="central_bank", description="🏛️ детальная статистика ЦБ"),
|
||||||
|
BotCommand(command="cb_rules", description="📖 правила ЦБ"),
|
||||||
]
|
]
|
||||||
scopes = (
|
scopes = (
|
||||||
BotCommandScopeDefault(),
|
BotCommandScopeDefault(),
|
||||||
|
|
@ -1499,6 +1710,9 @@ def main():
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Не удалось выставить команды, но бот продолжит работу.")
|
logger.warning("Не удалось выставить команды, но бот продолжит работу.")
|
||||||
|
|
||||||
|
# Запуск фоновых задач экономики
|
||||||
|
asyncio.create_task(economy_background_tasks())
|
||||||
|
|
||||||
dp.startup.register(on_startup)
|
dp.startup.register(on_startup)
|
||||||
|
|
||||||
async def start_settle(bot: Bot):
|
async def start_settle(bot: Bot):
|
||||||
|
|
@ -1508,5 +1722,29 @@ def main():
|
||||||
logger.info("Бот запущен...")
|
logger.info("Бот запущен...")
|
||||||
asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()))
|
asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()))
|
||||||
|
|
||||||
|
|
||||||
|
async def economy_background_tasks():
|
||||||
|
"""Фоновые задачи для экономики"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
moscow_time = datetime.now(ZoneInfo("Europe/Moscow"))
|
||||||
|
current_hour = moscow_time.hour
|
||||||
|
current_minute = moscow_time.minute
|
||||||
|
|
||||||
|
await asyncio.to_thread(economy.collect_daily_taxes)
|
||||||
|
await asyncio.to_thread(economy.regulate_inflation)
|
||||||
|
await asyncio.to_thread(economy.process_matured_deposits)
|
||||||
|
|
||||||
|
if current_hour == 0 and current_minute == 0:
|
||||||
|
users_paid = await asyncio.to_thread(economy.pay_daily_activity_rewards)
|
||||||
|
if users_paid > 0:
|
||||||
|
logger.info(f"Activity rewards paid to {users_paid} users at 00:00 MSK")
|
||||||
|
|
||||||
|
logger.info(f"Economy background tasks completed at {moscow_time.strftime('%H:%M:%S MSK')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Economy background task failed: {e}")
|
||||||
|
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,4 @@ aiohttp>=3.9.5
|
||||||
tzdata>=2024.1
|
tzdata>=2024.1
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
lxml
|
lxml
|
||||||
pysqlite3-binary>=0.5.3
|
psycopg2-binary>=2.9.9
|
||||||
|
|
|
||||||
369
scripts/migrate_sqlite_to_pg.py
Normal file
369
scripts/migrate_sqlite_to_pg.py
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migrate data from SQLite databases to PostgreSQL.
|
||||||
|
|
||||||
|
Usage (from repo root):
|
||||||
|
docker compose run --rm bot python3 scripts/migrate_sqlite_to_pg.py
|
||||||
|
|
||||||
|
Or locally (PostgreSQL must be reachable):
|
||||||
|
DATABASE_URL=postgresql://bot:botpass@localhost:5432/botdb python3 scripts/migrate_sqlite_to_pg.py
|
||||||
|
|
||||||
|
SQLite sources expected in ./db/:
|
||||||
|
economy.sqlite3 → user_balances, deposits, loans, transactions, taxes,
|
||||||
|
economy_settings, daily_activity, central_bank, penis_stats
|
||||||
|
bets.sqlite3 → bets
|
||||||
|
chat_history.sqlite3→ chat_history, chat_state, talk_history, talk_state
|
||||||
|
penis_stats.sqlite3 → (legacy) length column → user_balances.balance + penis_stats
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
|
DB_DIR = Path(os.getenv("SQLITE_DIR", "/db"))
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
|
||||||
|
|
||||||
|
ECONOMY_DB = DB_DIR / "economy.sqlite3"
|
||||||
|
BETS_DB = DB_DIR / "bets.sqlite3"
|
||||||
|
CHAT_DB = DB_DIR / "chat_history.sqlite3"
|
||||||
|
PENIS_LEGACY_DB = DB_DIR / "penis_stats.sqlite3"
|
||||||
|
|
||||||
|
|
||||||
|
def open_sqlite(path: Path) -> sqlite3.Connection | None:
|
||||||
|
if not path.exists():
|
||||||
|
print(f" [skip] {path} not found")
|
||||||
|
return None
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_economy(pg: psycopg2.extensions.connection) -> None:
|
||||||
|
src = open_sqlite(ECONOMY_DB)
|
||||||
|
if src is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
cur = pg.cursor()
|
||||||
|
|
||||||
|
# --- user_balances ---
|
||||||
|
rows = src.execute("SELECT * FROM user_balances").fetchall()
|
||||||
|
print(f" user_balances: {len(rows)} rows")
|
||||||
|
for r in rows:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
balance = EXCLUDED.balance,
|
||||||
|
daily_income = EXCLUDED.daily_income,
|
||||||
|
last_daily_reset = EXCLUDED.last_daily_reset
|
||||||
|
""", (
|
||||||
|
r["user_id"],
|
||||||
|
r["balance"],
|
||||||
|
r["daily_income"],
|
||||||
|
r["last_daily_reset"] or None,
|
||||||
|
r["created_at"] or None,
|
||||||
|
))
|
||||||
|
|
||||||
|
# --- deposits ---
|
||||||
|
rows = src.execute("SELECT * FROM deposits").fetchall()
|
||||||
|
print(f" deposits: {len(rows)} rows")
|
||||||
|
for r in rows:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO deposits (id, user_id, amount, interest_rate, created_at, matures_at, is_active)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""", (
|
||||||
|
r["id"], r["user_id"], r["amount"], r["interest_rate"],
|
||||||
|
r["created_at"] or None, r["matures_at"] or None,
|
||||||
|
bool(r["is_active"]),
|
||||||
|
))
|
||||||
|
_reset_sequence(cur, "deposits", "id")
|
||||||
|
|
||||||
|
# --- loans ---
|
||||||
|
rows = src.execute("SELECT * FROM loans").fetchall()
|
||||||
|
print(f" loans: {len(rows)} rows")
|
||||||
|
for r in rows:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO loans (id, user_id, amount, interest_rate, created_at, due_at, is_repaid)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""", (
|
||||||
|
r["id"], r["user_id"], r["amount"], r["interest_rate"],
|
||||||
|
r["created_at"] or None, r["due_at"] or None,
|
||||||
|
bool(r["is_repaid"]),
|
||||||
|
))
|
||||||
|
_reset_sequence(cur, "loans", "id")
|
||||||
|
|
||||||
|
# --- transactions ---
|
||||||
|
rows = src.execute("SELECT * FROM transactions").fetchall()
|
||||||
|
print(f" transactions: {len(rows)} rows")
|
||||||
|
for r in rows:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO transactions (id, from_user_id, to_user_id, amount, transaction_type, description, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""", (
|
||||||
|
r["id"], r["from_user_id"], r["to_user_id"], r["amount"],
|
||||||
|
r["transaction_type"], r["description"], r["created_at"] or None,
|
||||||
|
))
|
||||||
|
_reset_sequence(cur, "transactions", "id")
|
||||||
|
|
||||||
|
# --- taxes ---
|
||||||
|
rows = src.execute("SELECT * FROM taxes").fetchall()
|
||||||
|
print(f" taxes: {len(rows)} rows")
|
||||||
|
for r in rows:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO taxes (id, user_id, amount, tax_date, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""", (
|
||||||
|
r["id"], r["user_id"], r["amount"],
|
||||||
|
r["tax_date"] or None, r["created_at"] or None,
|
||||||
|
))
|
||||||
|
_reset_sequence(cur, "taxes", "id")
|
||||||
|
|
||||||
|
# --- economy_settings ---
|
||||||
|
rows = src.execute("SELECT * FROM economy_settings").fetchall()
|
||||||
|
print(f" economy_settings: {len(rows)} rows")
|
||||||
|
for r in rows:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO economy_settings (key, value, updated_at)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", (r["key"], r["value"], r["updated_at"] if "updated_at" in r.keys() else None))
|
||||||
|
|
||||||
|
# --- daily_activity ---
|
||||||
|
rows = src.execute("SELECT * FROM daily_activity").fetchall()
|
||||||
|
print(f" daily_activity: {len(rows)} rows")
|
||||||
|
for r in rows:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO daily_activity (id, user_id, activity_date, message_count, last_activity_time, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (user_id, activity_date) DO UPDATE SET
|
||||||
|
message_count = EXCLUDED.message_count,
|
||||||
|
last_activity_time = EXCLUDED.last_activity_time
|
||||||
|
""", (
|
||||||
|
r["id"], r["user_id"],
|
||||||
|
r["activity_date"] or None, r["message_count"],
|
||||||
|
r["last_activity_time"] or None, r["created_at"] or None,
|
||||||
|
))
|
||||||
|
_reset_sequence(cur, "daily_activity", "id")
|
||||||
|
|
||||||
|
# --- central_bank ---
|
||||||
|
rows = src.execute("SELECT * FROM central_bank").fetchall()
|
||||||
|
print(f" central_bank: {len(rows)} rows")
|
||||||
|
for r in rows:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO central_bank (id, capital, total_taxes_collected,
|
||||||
|
total_inflation_adjustments, total_emissions, last_updated)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
capital = EXCLUDED.capital,
|
||||||
|
total_taxes_collected = EXCLUDED.total_taxes_collected,
|
||||||
|
total_inflation_adjustments = EXCLUDED.total_inflation_adjustments,
|
||||||
|
total_emissions = EXCLUDED.total_emissions,
|
||||||
|
last_updated = EXCLUDED.last_updated
|
||||||
|
""", (
|
||||||
|
r["id"], r["capital"],
|
||||||
|
r["total_taxes_collected"], r["total_inflation_adjustments"],
|
||||||
|
r["total_emissions"], r["last_updated"] or None,
|
||||||
|
))
|
||||||
|
|
||||||
|
# --- penis_stats (new schema: user_id, display_name, last_used_ts) ---
|
||||||
|
try:
|
||||||
|
rows = src.execute("SELECT * FROM penis_stats").fetchall()
|
||||||
|
print(f" penis_stats: {len(rows)} rows")
|
||||||
|
cols = rows[0].keys() if rows else []
|
||||||
|
for r in rows:
|
||||||
|
if "last_used_ts" in cols:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO penis_stats (user_id, display_name, last_used_ts)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
display_name = EXCLUDED.display_name,
|
||||||
|
last_used_ts = EXCLUDED.last_used_ts
|
||||||
|
""", (r["user_id"], r["display_name"], r["last_used_ts"]))
|
||||||
|
else:
|
||||||
|
# old schema without last_used_ts
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO penis_stats (user_id, display_name)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET display_name = EXCLUDED.display_name
|
||||||
|
""", (r["user_id"], r.get("display_name", "")))
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
print(f" [warn] penis_stats in economy.sqlite3: {e}")
|
||||||
|
|
||||||
|
src.close()
|
||||||
|
pg.commit()
|
||||||
|
print(" economy.sqlite3 done")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_legacy_penis(pg: psycopg2.extensions.connection) -> None:
|
||||||
|
"""Old penis_stats.sqlite3 had a `length` column — map it to user_balances.balance."""
|
||||||
|
src = open_sqlite(PENIS_LEGACY_DB)
|
||||||
|
if src is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
cur = pg.cursor()
|
||||||
|
try:
|
||||||
|
rows = src.execute("SELECT user_id, length, display_name FROM penis_stats").fetchall()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
rows = src.execute("SELECT * FROM penis_stats").fetchall()
|
||||||
|
|
||||||
|
print(f" penis_stats.sqlite3 (legacy): {len(rows)} rows")
|
||||||
|
for r in rows:
|
||||||
|
uid = r["user_id"]
|
||||||
|
length = r["length"] if "length" in r.keys() else 0.0
|
||||||
|
name = r["display_name"] if "display_name" in r.keys() else ""
|
||||||
|
last_ts = r["last_used_ts"] if "last_used_ts" in r.keys() else None
|
||||||
|
|
||||||
|
# upsert into user_balances; don't overwrite if already migrated from economy.sqlite3
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO user_balances (user_id, balance)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT (user_id) DO NOTHING
|
||||||
|
""", (uid, float(length)))
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO penis_stats (user_id, display_name, last_used_ts)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
display_name = EXCLUDED.display_name,
|
||||||
|
last_used_ts = COALESCE(EXCLUDED.last_used_ts, penis_stats.last_used_ts)
|
||||||
|
""", (uid, name, last_ts))
|
||||||
|
|
||||||
|
src.close()
|
||||||
|
pg.commit()
|
||||||
|
print(" penis_stats.sqlite3 done")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_bets(pg: psycopg2.extensions.connection) -> None:
|
||||||
|
src = open_sqlite(BETS_DB)
|
||||||
|
if src is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
cur = pg.cursor()
|
||||||
|
|
||||||
|
rows = src.execute("SELECT * FROM bets").fetchall()
|
||||||
|
print(f" bets: {len(rows)} rows")
|
||||||
|
cols = rows[0].keys() if rows else []
|
||||||
|
for r in rows:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO bets (id, user_id, match_id, sport, home_team, away_team,
|
||||||
|
chosen_team, amount, odds, status, payout, created_ts, resolved_ts)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""", (
|
||||||
|
r["id"], r["user_id"], r["match_id"], r["sport"],
|
||||||
|
r["home_team"], r["away_team"], r["chosen_team"],
|
||||||
|
r["amount"], r["odds"], r["status"],
|
||||||
|
r["payout"] if "payout" in cols else 0,
|
||||||
|
r["created_ts"] if "created_ts" in cols else None,
|
||||||
|
r["resolved_ts"] if "resolved_ts" in cols else None,
|
||||||
|
))
|
||||||
|
_reset_sequence(cur, "bets", "id")
|
||||||
|
|
||||||
|
src.close()
|
||||||
|
pg.commit()
|
||||||
|
print(" bets.sqlite3 done")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_chat(pg: psycopg2.extensions.connection) -> None:
|
||||||
|
src = open_sqlite(CHAT_DB)
|
||||||
|
if src is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
cur = pg.cursor()
|
||||||
|
|
||||||
|
# --- chat_history ---
|
||||||
|
rows = src.execute("SELECT * FROM chat_history").fetchall()
|
||||||
|
print(f" chat_history: {len(rows)} rows")
|
||||||
|
cols = rows[0].keys() if rows else []
|
||||||
|
for r in rows:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO chat_history (id, chat_id, role, name, text, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""", (
|
||||||
|
r["id"], r["chat_id"], r["role"], r["name"], r["text"],
|
||||||
|
r["created_at"] if "created_at" in cols else 0,
|
||||||
|
))
|
||||||
|
_reset_sequence(cur, "chat_history", "id")
|
||||||
|
|
||||||
|
# --- chat_state ---
|
||||||
|
rows = src.execute("SELECT * FROM chat_state").fetchall()
|
||||||
|
print(f" chat_state: {len(rows)} rows")
|
||||||
|
for r in rows:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO chat_state (chat_id, key, value)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (chat_id, key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", (r["chat_id"], r["key"], r["value"]))
|
||||||
|
|
||||||
|
# --- talk_history ---
|
||||||
|
rows = src.execute("SELECT * FROM talk_history").fetchall()
|
||||||
|
print(f" talk_history: {len(rows)} rows")
|
||||||
|
cols = rows[0].keys() if rows else []
|
||||||
|
for r in rows:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO talk_history (id, chat_id, user_id, role, name, text, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""", (
|
||||||
|
r["id"], r["chat_id"], r["user_id"], r["role"], r["name"], r["text"],
|
||||||
|
r["created_at"] if "created_at" in cols else 0,
|
||||||
|
))
|
||||||
|
_reset_sequence(cur, "talk_history", "id")
|
||||||
|
|
||||||
|
# --- talk_state ---
|
||||||
|
rows = src.execute("SELECT * FROM talk_state").fetchall()
|
||||||
|
print(f" talk_state: {len(rows)} rows")
|
||||||
|
for r in rows:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO talk_state (chat_id, user_id, key, value)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
ON CONFLICT (chat_id, user_id, key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", (r["chat_id"], r["user_id"], r["key"], r["value"]))
|
||||||
|
|
||||||
|
src.close()
|
||||||
|
pg.commit()
|
||||||
|
print(" chat_history.sqlite3 done")
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_sequence(cur, table: str, col: str) -> None:
|
||||||
|
"""Reset SERIAL sequence to max(col) so future inserts don't collide."""
|
||||||
|
cur.execute(f"SELECT setval(pg_get_serial_sequence('{table}', '{col}'), COALESCE(MAX({col}), 1)) FROM {table}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print(f"Connecting to PostgreSQL: {DATABASE_URL}")
|
||||||
|
try:
|
||||||
|
pg = psycopg2.connect(DATABASE_URL)
|
||||||
|
except psycopg2.OperationalError as e:
|
||||||
|
print(f"ERROR: cannot connect to PostgreSQL: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
pg.autocommit = False
|
||||||
|
|
||||||
|
print("\n=== economy.sqlite3 ===")
|
||||||
|
migrate_economy(pg)
|
||||||
|
|
||||||
|
print("\n=== penis_stats.sqlite3 (legacy) ===")
|
||||||
|
migrate_legacy_penis(pg)
|
||||||
|
|
||||||
|
print("\n=== bets.sqlite3 ===")
|
||||||
|
migrate_bets(pg)
|
||||||
|
|
||||||
|
print("\n=== chat_history.sqlite3 ===")
|
||||||
|
migrate_chat(pg)
|
||||||
|
|
||||||
|
pg.close()
|
||||||
|
print("\nMigration complete.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
72
scripts/set_telegram_webapp_button.py
Executable file
72
scripts/set_telegram_webapp_button.py
Executable file
|
|
@ -0,0 +1,72 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_env_file(path: Path) -> None:
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
# сохраняем первое значение и игнорируем shell-комментарий после пробела
|
||||||
|
value = value.split(" #", 1)[0].strip()
|
||||||
|
if key not in os.environ:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def api_call(token: str, method: str, payload: dict) -> dict:
|
||||||
|
url = f"https://api.telegram.org/bot{token}/{method}"
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
load_env_file(root / ".env")
|
||||||
|
|
||||||
|
token = os.getenv("BOT_TOKEN", "").strip()
|
||||||
|
webapp_url = os.getenv("WEBAPP_URL", "").strip()
|
||||||
|
if not token:
|
||||||
|
print("BOT_TOKEN пустой в .env", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not webapp_url:
|
||||||
|
print("WEBAPP_URL пустой в .env", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
parsed = urllib.parse.urlparse(webapp_url)
|
||||||
|
if parsed.scheme != "https":
|
||||||
|
print("WEBAPP_URL должен быть https", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"menu_button": {
|
||||||
|
"type": "web_app",
|
||||||
|
"text": "Mini App",
|
||||||
|
"web_app": {"url": webapp_url},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = api_call(token, "setChatMenuButton", payload)
|
||||||
|
if not result.get("ok"):
|
||||||
|
print(f"setChatMenuButton failed: {result}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"Menu button updated: {webapp_url}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
97
scripts/start_webapp_ngrok_test.sh
Executable file
97
scripts/start_webapp_ngrok_test.sh
Executable file
|
|
@ -0,0 +1,97 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
COMPOSE_FILE="${ROOT_DIR}/docker-compose.webapp-test.yml"
|
||||||
|
ENV_FILE="${ROOT_DIR}/.env"
|
||||||
|
PROJECT_NAME="fenya-webapp-test"
|
||||||
|
|
||||||
|
if [[ ! -f "${COMPOSE_FILE}" ]]; then
|
||||||
|
echo "Не найден ${COMPOSE_FILE}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||||
|
echo "Не найден ${ENV_FILE}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -qE '^NGROK_AUTHTOKEN=' "${ENV_FILE}"; then
|
||||||
|
fallback_token="$(python - <<'PY'
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
p = Path("webapp/ngrok_tunnel.py")
|
||||||
|
if not p.exists():
|
||||||
|
print("")
|
||||||
|
raise SystemExit
|
||||||
|
txt = p.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
m = re.search(r'auth_token\s*=\s*"([^"]+)"', txt)
|
||||||
|
print(m.group(1) if m else "")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
if [[ -n "${fallback_token}" ]]; then
|
||||||
|
printf '\nNGROK_AUTHTOKEN=%s\n' "${fallback_token}" >> "${ENV_FILE}"
|
||||||
|
echo "Добавил NGROK_AUTHTOKEN в .env из webapp/ngrok_tunnel.py"
|
||||||
|
else
|
||||||
|
echo "NGROK_AUTHTOKEN не найден. Добавь в .env строку NGROK_AUTHTOKEN=..." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
docker compose -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" up -d --build
|
||||||
|
|
||||||
|
echo "Жду инициализацию ngrok..."
|
||||||
|
public_url=""
|
||||||
|
for _ in $(seq 1 45); do
|
||||||
|
public_url="$(python - <<'PY'
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen("http://127.0.0.1:4041/api/tunnels", timeout=2) as resp:
|
||||||
|
data = json.load(resp)
|
||||||
|
tunnels = data.get("tunnels", [])
|
||||||
|
for t in tunnels:
|
||||||
|
url = str(t.get("public_url", ""))
|
||||||
|
if url.startswith("https://"):
|
||||||
|
print(url)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
if [[ -n "${public_url}" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "${public_url}" ]]; then
|
||||||
|
echo "Не удалось получить ngrok URL. Проверь логи: docker logs fenyabot-ngrok-test" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
python - <<'PY' "${ENV_FILE}" "${public_url}"
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
env_path = Path(sys.argv[1])
|
||||||
|
url = sys.argv[2]
|
||||||
|
text = env_path.read_text(encoding="utf-8")
|
||||||
|
if re.search(r"^WEBAPP_URL=.*$", text, flags=re.MULTILINE):
|
||||||
|
text = re.sub(r"^WEBAPP_URL=.*$", f"WEBAPP_URL={url}", text, flags=re.MULTILINE)
|
||||||
|
else:
|
||||||
|
text = text.rstrip() + f"\nWEBAPP_URL={url}\n"
|
||||||
|
env_path.write_text(text, encoding="utf-8")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Готово."
|
||||||
|
echo "NGROK URL: ${public_url}"
|
||||||
|
echo "WEBAPP_URL обновлён в .env"
|
||||||
|
echo
|
||||||
|
echo "Для остановки тест-контура:"
|
||||||
|
echo " docker compose -p ${PROJECT_NAME} -f ${COMPOSE_FILE} down"
|
||||||
9
scripts/stop_webapp_ngrok_test.sh
Executable file
9
scripts/stop_webapp_ngrok_test.sh
Executable file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
COMPOSE_FILE="${ROOT_DIR}/docker-compose.webapp-test.yml"
|
||||||
|
PROJECT_NAME="fenya-webapp-test"
|
||||||
|
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
docker compose -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" down
|
||||||
148
webapp/api.py
148
webapp/api.py
|
|
@ -10,6 +10,8 @@ import logging
|
||||||
import os
|
import os
|
||||||
import hashlib
|
import hashlib
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
from urllib.parse import urlparse
|
||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
@ -17,7 +19,7 @@ from typing import Annotated
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from fastapi import FastAPI, Depends, Header, HTTPException, Request
|
from fastapi import FastAPI, Depends, Header, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
@ -36,6 +38,7 @@ from games.betting import (
|
||||||
)
|
)
|
||||||
from webapp.auth import validate_init_data
|
from webapp.auth import validate_init_data
|
||||||
from games.uwu import fetch_uwu_post, fetch_furtok_feed
|
from games.uwu import fetch_uwu_post, fetch_furtok_feed
|
||||||
|
from webapp.shorties import fetch_shorties_feed
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -142,15 +145,16 @@ async def get_profile(user: dict = Depends(get_current_user)) -> ProfileResponse
|
||||||
user_id = user["user_id"]
|
user_id = user["user_id"]
|
||||||
length = await asyncio.to_thread(get_user_length, user_id)
|
length = await asyncio.to_thread(get_user_length, user_id)
|
||||||
|
|
||||||
# Считаем активные ставки
|
|
||||||
import sqlite3
|
|
||||||
active_bets = 0
|
active_bets = 0
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
from db import get_conn
|
||||||
row = conn.execute(
|
with get_conn() as conn:
|
||||||
"SELECT COUNT(*) FROM bets WHERE user_id = ? AND status = 'pending'",
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) FROM bets WHERE user_id = %s AND status = 'pending'",
|
||||||
(user_id,),
|
(user_id,),
|
||||||
).fetchone()
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
active_bets = row[0] if row else 0
|
active_bets = row[0] if row else 0
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -405,6 +409,23 @@ async def get_furtok_feed_api(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/furtok/shorties")
|
||||||
|
async def get_shorties_feed_api(
|
||||||
|
page: int = 1,
|
||||||
|
count: int = 8,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Случайная shorties-лента с Pornhub."""
|
||||||
|
try:
|
||||||
|
# page используем как "seed-сдвиг" для подгрузки следующих рандомных наборов.
|
||||||
|
random_pages = 2 + min(max(page, 1), 6)
|
||||||
|
feed = await fetch_shorties_feed(count=count, random_pages=random_pages)
|
||||||
|
return {"feed": feed}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Shorties API failed")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/proxy-image")
|
@app.get("/api/proxy-image")
|
||||||
async def proxy_image(url: str):
|
async def proxy_image(url: str):
|
||||||
"""Проксирует картинку с e621 чтобы обойти hotlink protection."""
|
"""Проксирует картинку с e621 чтобы обойти hotlink protection."""
|
||||||
|
|
@ -433,6 +454,119 @@ async def proxy_image(url: str):
|
||||||
raise HTTPException(status_code=502, detail="Failed to fetch image")
|
raise HTTPException(status_code=502, detail="Failed to fetch image")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_allowed_shorties_media_host(hostname: str) -> bool:
|
||||||
|
host = (hostname or "").lower()
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
if host in {"www.pornhub.com", "pornhub.com", "phncdn.com"}:
|
||||||
|
return True
|
||||||
|
if host.endswith(".phncdn.com"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/proxy-shorties-media")
|
||||||
|
async def proxy_shorties_media(url: str, request: Request):
|
||||||
|
"""Проксирует видео shorties, чтобы избежать ограничений hotlink/CORS."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not _is_allowed_shorties_media_host(parsed.hostname or ""):
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden host")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
"Referer": "https://www.pornhub.com/shorties",
|
||||||
|
"Origin": "https://www.pornhub.com",
|
||||||
|
}
|
||||||
|
range_header = request.headers.get("range")
|
||||||
|
if range_header:
|
||||||
|
headers["Range"] = range_header
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=120)
|
||||||
|
session = aiohttp.ClientSession(timeout=timeout)
|
||||||
|
try:
|
||||||
|
resp = await session.get(url, headers=headers, allow_redirects=True)
|
||||||
|
except aiohttp.ClientError:
|
||||||
|
await session.close()
|
||||||
|
raise HTTPException(status_code=502, detail="Failed to fetch media")
|
||||||
|
|
||||||
|
if resp.status not in {200, 206}:
|
||||||
|
await resp.release()
|
||||||
|
await session.close()
|
||||||
|
raise HTTPException(status_code=resp.status, detail="Upstream error")
|
||||||
|
|
||||||
|
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
||||||
|
|
||||||
|
if ".m3u8" in parsed.path.lower() or "mpegurl" in content_type.lower():
|
||||||
|
try:
|
||||||
|
body = await resp.text()
|
||||||
|
finally:
|
||||||
|
await resp.release()
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
def _proxy_media_url(target_url: str) -> str:
|
||||||
|
return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}"
|
||||||
|
|
||||||
|
rewritten_lines: list[str] = []
|
||||||
|
for raw_line in body.splitlines():
|
||||||
|
stripped = raw_line.strip()
|
||||||
|
if not stripped:
|
||||||
|
rewritten_lines.append(raw_line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if stripped.startswith("#EXT-X-KEY") and 'URI="' in raw_line:
|
||||||
|
prefix, rest = raw_line.split('URI="', 1)
|
||||||
|
original_uri, suffix = rest.split('"', 1)
|
||||||
|
absolute_uri = urljoin(url, original_uri)
|
||||||
|
rewritten_lines.append(f'{prefix}URI="{_proxy_media_url(absolute_uri)}"{suffix}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
if stripped.startswith("#"):
|
||||||
|
rewritten_lines.append(raw_line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
absolute_uri = urljoin(url, stripped)
|
||||||
|
rewritten_lines.append(_proxy_media_url(absolute_uri))
|
||||||
|
|
||||||
|
playlist_body = "\n".join(rewritten_lines)
|
||||||
|
return Response(
|
||||||
|
content=playlist_body,
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "public, max-age=120",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
passthrough_headers = {
|
||||||
|
"Cache-Control": "public, max-age=600",
|
||||||
|
"Accept-Ranges": resp.headers.get("Accept-Ranges", "bytes"),
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
}
|
||||||
|
if resp.headers.get("Content-Length"):
|
||||||
|
passthrough_headers["Content-Length"] = resp.headers["Content-Length"]
|
||||||
|
if resp.headers.get("Content-Range"):
|
||||||
|
passthrough_headers["Content-Range"] = resp.headers["Content-Range"]
|
||||||
|
|
||||||
|
async def _stream():
|
||||||
|
try:
|
||||||
|
async for chunk in resp.content.iter_chunked(64 * 1024):
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
await resp.release()
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_stream(),
|
||||||
|
media_type=content_type,
|
||||||
|
status_code=resp.status,
|
||||||
|
headers=passthrough_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- ИИ Чат ---
|
# --- ИИ Чат ---
|
||||||
|
|
||||||
@app.post("/api/talk")
|
@app.post("/api/talk")
|
||||||
|
|
|
||||||
|
|
@ -912,7 +912,8 @@ body::before {
|
||||||
}
|
}
|
||||||
|
|
||||||
.furtok-card video,
|
.furtok-card video,
|
||||||
.furtok-card img {
|
.furtok-card img,
|
||||||
|
.furtok-card iframe {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
@ -920,6 +921,7 @@ body::before {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.furtok-overlay {
|
.furtok-overlay {
|
||||||
|
|
@ -936,6 +938,36 @@ body::before {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.furtok-side-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 4;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-share-btn {
|
||||||
|
border: 1px solid rgba(255,255,255,0.35);
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
transition: transform 0.15s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-share-btn:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
background: rgba(0,0,0,0.65);
|
||||||
|
}
|
||||||
|
|
||||||
.furtok-caption {
|
.furtok-caption {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
@ -989,6 +1021,37 @@ body::before {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.furtok-mode-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.14);
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-mode-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 5px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-mode-btn.active {
|
||||||
|
background: rgba(96, 165, 250, 0.3);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-mode-btn:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
.furtok-gear-btn {
|
.furtok-gear-btn {
|
||||||
background: rgba(255,255,255,0.15);
|
background: rgba(255,255,255,0.15);
|
||||||
border: none;
|
border: none;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
|
|
||||||
|
|
@ -42,5 +42,7 @@ export const API = {
|
||||||
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
|
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
|
||||||
getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`),
|
getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`),
|
||||||
getFurtokFeed: (safe = true, page = 1, tags = '') => api(`/api/furtok?safe=${safe}&page=${page}${tags ? '&tags=' + encodeURIComponent(tags) : ''}`),
|
getFurtokFeed: (safe = true, page = 1, tags = '') => api(`/api/furtok?safe=${safe}&page=${page}${tags ? '&tags=' + encodeURIComponent(tags) : ''}`),
|
||||||
|
getShortiesFeed: (page = 1, count = 8) => api(`/api/furtok/shorties?page=${page}&count=${count}`),
|
||||||
|
proxyShortiesMediaUrl: (url) => `${API_BASE}/api/proxy-shorties-media?url=${encodeURIComponent(url || '')}`,
|
||||||
talk: (text) => api('/api/talk', { method: 'POST', body: { text } }),
|
talk: (text) => api('/api/talk', { method: 'POST', body: { text } }),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ const navBtns = document.querySelectorAll('.nav-btn');
|
||||||
|
|
||||||
// ── Router ──
|
// ── Router ──
|
||||||
function navigate(page, data = null) {
|
function navigate(page, data = null) {
|
||||||
|
document.querySelectorAll('.furtok-card video').forEach((video) => {
|
||||||
|
if (video && video._hlsInstance) {
|
||||||
|
try { video._hlsInstance.destroy(); } catch {}
|
||||||
|
video._hlsInstance = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
currentPage = page;
|
currentPage = page;
|
||||||
haptic();
|
haptic();
|
||||||
// Убираем оверлеи при смене страницы
|
// Убираем оверлеи при смене страницы
|
||||||
|
|
@ -66,6 +72,82 @@ function $(html) {
|
||||||
return t.content.firstChild;
|
return t.content.firstChild;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.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') {
|
function showResult(icon, title, desc, btnText = 'OK') {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const overlay = $(`
|
const overlay = $(`
|
||||||
|
|
@ -507,6 +589,7 @@ let furtokWrapper = null;
|
||||||
let furtokCurrentIndex = 0;
|
let furtokCurrentIndex = 0;
|
||||||
let furtokCards = [];
|
let furtokCards = [];
|
||||||
let furtokCustomTags = '';
|
let furtokCustomTags = '';
|
||||||
|
let furtokMode = 'furtok'; // "furtok" | "shorties"
|
||||||
|
|
||||||
// Подписи из uwu.py — рандомно появляются на карточках
|
// Подписи из uwu.py — рандомно появляются на карточках
|
||||||
const FURTOK_CAPTIONS = [
|
const FURTOK_CAPTIONS = [
|
||||||
|
|
@ -531,6 +614,7 @@ function randomCaption() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function _furtokGetTags() {
|
function _furtokGetTags() {
|
||||||
|
if (furtokMode === 'shorties') return '';
|
||||||
// Если кастомные теги заданы — используем их (safe управляется юзером через теги)
|
// Если кастомные теги заданы — используем их (safe управляется юзером через теги)
|
||||||
if (furtokCustomTags.trim()) return furtokCustomTags.trim();
|
if (furtokCustomTags.trim()) return furtokCustomTags.trim();
|
||||||
// Иначе стандартный запрос с safe-переключателем
|
// Иначе стандартный запрос с safe-переключателем
|
||||||
|
|
@ -546,6 +630,29 @@ function _furtokReload(feed) {
|
||||||
loadFurtokPage(feed);
|
loadFurtokPage(feed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _updateFurtokUiMode() {
|
||||||
|
const safeWrap = document.getElementById('furtok-safe-wrap');
|
||||||
|
const gearBtn = document.getElementById('furtok-gear');
|
||||||
|
const tagsPanel = document.getElementById('furtok-tags-panel');
|
||||||
|
const title = document.getElementById('furtok-title');
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
title.textContent = furtokMode === 'shorties' ? '🔥 Shorties' : '🐺 FurTok';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!safeWrap || !gearBtn || !tagsPanel) return;
|
||||||
|
|
||||||
|
if (furtokMode === 'shorties') {
|
||||||
|
safeWrap.style.display = 'none';
|
||||||
|
gearBtn.style.display = 'none';
|
||||||
|
tagsPanel.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gearBtn.style.display = '';
|
||||||
|
safeWrap.style.display = furtokCustomTags ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
function renderFurtok() {
|
function renderFurtok() {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
document.querySelector('.furtok-wrapper')?.remove();
|
document.querySelector('.furtok-wrapper')?.remove();
|
||||||
|
|
@ -559,8 +666,12 @@ function renderFurtok() {
|
||||||
furtokWrapper.className = 'furtok-wrapper';
|
furtokWrapper.className = 'furtok-wrapper';
|
||||||
furtokWrapper.innerHTML = `
|
furtokWrapper.innerHTML = `
|
||||||
<div class="furtok-header">
|
<div class="furtok-header">
|
||||||
<div class="furtok-title">🐺 FurTok</div>
|
<div class="furtok-title" id="furtok-title">${furtokMode === 'shorties' ? '🔥 Shorties' : '🐺 FurTok'}</div>
|
||||||
<div class="furtok-header-right">
|
<div class="furtok-header-right">
|
||||||
|
<div class="furtok-mode-switch" id="furtok-mode-switch">
|
||||||
|
<button class="furtok-mode-btn ${furtokMode === 'furtok' ? 'active' : ''}" data-mode="furtok">FurTok</button>
|
||||||
|
<button class="furtok-mode-btn ${furtokMode === 'shorties' ? 'active' : ''}" data-mode="shorties">Shorties</button>
|
||||||
|
</div>
|
||||||
<label class="furtok-toggle" id="furtok-safe-wrap" ${furtokCustomTags.trim() ? 'style="display:none"' : ''}>
|
<label class="furtok-toggle" id="furtok-safe-wrap" ${furtokCustomTags.trim() ? 'style="display:none"' : ''}>
|
||||||
<span>Safe</span>
|
<span>Safe</span>
|
||||||
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}>
|
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}>
|
||||||
|
|
@ -588,6 +699,23 @@ function renderFurtok() {
|
||||||
const tagsPanel = document.getElementById('furtok-tags-panel');
|
const tagsPanel = document.getElementById('furtok-tags-panel');
|
||||||
const tagsInput = document.getElementById('furtok-tags-input');
|
const tagsInput = document.getElementById('furtok-tags-input');
|
||||||
const tagsApply = document.getElementById('furtok-tags-apply');
|
const tagsApply = document.getElementById('furtok-tags-apply');
|
||||||
|
const modeSwitch = document.getElementById('furtok-mode-switch');
|
||||||
|
|
||||||
|
_updateFurtokUiMode();
|
||||||
|
|
||||||
|
modeSwitch?.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.furtok-mode-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
const nextMode = btn.dataset.mode;
|
||||||
|
if (!nextMode || nextMode === furtokMode) return;
|
||||||
|
haptic();
|
||||||
|
furtokMode = nextMode;
|
||||||
|
modeSwitch.querySelectorAll('.furtok-mode-btn').forEach((item) => {
|
||||||
|
item.classList.toggle('active', item.dataset.mode === furtokMode);
|
||||||
|
});
|
||||||
|
_updateFurtokUiMode();
|
||||||
|
_furtokReload(feed);
|
||||||
|
});
|
||||||
|
|
||||||
// Safe toggle
|
// Safe toggle
|
||||||
safeToggle.addEventListener('change', () => {
|
safeToggle.addEventListener('change', () => {
|
||||||
|
|
@ -608,7 +736,7 @@ function renderFurtok() {
|
||||||
haptic();
|
haptic();
|
||||||
furtokCustomTags = tagsInput.value.trim();
|
furtokCustomTags = tagsInput.value.trim();
|
||||||
// Если кастомные теги — прячем Safe (юзер сам контролирует rating)
|
// Если кастомные теги — прячем Safe (юзер сам контролирует rating)
|
||||||
safeWrap.style.display = furtokCustomTags ? 'none' : '';
|
_updateFurtokUiMode();
|
||||||
tagsPanel.style.display = 'none';
|
tagsPanel.style.display = 'none';
|
||||||
_furtokReload(feed);
|
_furtokReload(feed);
|
||||||
});
|
});
|
||||||
|
|
@ -727,7 +855,9 @@ async function loadFurtokPage(feedEl) {
|
||||||
feedEl.appendChild(loader);
|
feedEl.appendChild(loader);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await API.getFurtokFeed(furtokSafe, furtokPage, furtokCustomTags);
|
const res = furtokMode === 'shorties'
|
||||||
|
? await API.getShortiesFeed(furtokPage, 8)
|
||||||
|
: await API.getFurtokFeed(furtokSafe, furtokPage, furtokCustomTags);
|
||||||
loader.remove();
|
loader.remove();
|
||||||
|
|
||||||
if (!res.feed || res.feed.length === 0) {
|
if (!res.feed || res.feed.length === 0) {
|
||||||
|
|
@ -741,23 +871,44 @@ async function loadFurtokPage(feedEl) {
|
||||||
res.feed.forEach(post => {
|
res.feed.forEach(post => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'furtok-card';
|
card.className = 'furtok-card';
|
||||||
|
const isShorties = furtokMode === 'shorties';
|
||||||
|
const shareUrl = getShortiesShareUrl(post);
|
||||||
|
|
||||||
let mediaHtml = '';
|
let mediaHtml = '';
|
||||||
if (post.type === 'image' || post.ext === 'gif') {
|
if (post.type === 'image' || post.ext === 'gif') {
|
||||||
mediaHtml = `<img src="${post.url}" loading="lazy">`;
|
mediaHtml = `<img src="${post.url}" loading="lazy" alt="">`;
|
||||||
} else {
|
} else {
|
||||||
mediaHtml = `<video src="${post.url}" loop playsinline preload="metadata" muted></video>`;
|
const poster = post.sample ? ` poster="${post.sample}"` : '';
|
||||||
|
const directMp4 = post.mp4_url || post.url || '';
|
||||||
|
const proxyMp4 = isShorties ? API.proxyShortiesMediaUrl(directMp4) : post.url;
|
||||||
|
const proxyHls = isShorties && post.hls_url ? API.proxyShortiesMediaUrl(post.hls_url) : '';
|
||||||
|
const useHls = isShorties && !directMp4 && Boolean(post.hls_url);
|
||||||
|
const videoSrc = useHls ? '' : proxyMp4;
|
||||||
|
const hlsSrc = isShorties ? escapeHtml(proxyHls) : '';
|
||||||
|
mediaHtml = `<video src="${videoSrc}" data-direct-src="${escapeHtml(directMp4)}" data-proxy-src="${escapeHtml(proxyMp4)}" data-hls-src="${hlsSrc}"${poster} loop autoplay playsinline preload="metadata" muted></video>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const caption = randomCaption();
|
const title = post.title ? escapeHtml(post.title) : '';
|
||||||
|
const caption = title || randomCaption();
|
||||||
|
const scoreLabel = furtokMode === 'shorties'
|
||||||
|
? `👁️ ${escapeHtml(post.views || '—')}`
|
||||||
|
: `⭐ ${escapeHtml(post.score ?? 0)}`;
|
||||||
|
const favLabel = furtokMode === 'shorties'
|
||||||
|
? `⏱️ ${escapeHtml(post.duration || '—')}`
|
||||||
|
: `❤️ ${escapeHtml(post.fav_count ?? 0)}`;
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
${mediaHtml}
|
${mediaHtml}
|
||||||
|
${isShorties ? `
|
||||||
|
<div class="furtok-side-actions">
|
||||||
|
<button class="furtok-share-btn" type="button" aria-label="Share">🔗 Share</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
<div class="furtok-overlay">
|
<div class="furtok-overlay">
|
||||||
<div class="furtok-caption">${caption}</div>
|
<div class="furtok-caption">${caption}</div>
|
||||||
<div class="furtok-stats">
|
<div class="furtok-stats">
|
||||||
<span>⭐ ${post.score}</span>
|
<span>${scoreLabel}</span>
|
||||||
<span>❤️ ${post.fav_count}</span>
|
<span>${favLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -765,6 +916,15 @@ async function loadFurtokPage(feedEl) {
|
||||||
// Тап по видео = unmute + play/pause
|
// Тап по видео = unmute + play/pause
|
||||||
const video = card.querySelector('video');
|
const video = card.querySelector('video');
|
||||||
if (video) {
|
if (video) {
|
||||||
|
if (isShorties) {
|
||||||
|
initShortiesVideoPlayback(video);
|
||||||
|
}
|
||||||
|
video.addEventListener('error', () => {
|
||||||
|
if (isShorties && video.dataset.proxySrc && video.src !== video.dataset.proxySrc) {
|
||||||
|
video.src = video.dataset.proxySrc;
|
||||||
|
video.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
video.muted = false;
|
video.muted = false;
|
||||||
if (video.paused) video.play();
|
if (video.paused) video.play();
|
||||||
|
|
@ -772,6 +932,23 @@ async function loadFurtokPage(feedEl) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shareBtn = card.querySelector('.furtok-share-btn');
|
||||||
|
if (shareBtn) {
|
||||||
|
shareBtn.addEventListener('click', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!shareUrl) return;
|
||||||
|
const copied = await copyToClipboard(shareUrl);
|
||||||
|
saveShortiesLinkLocally(shareUrl);
|
||||||
|
if (copied) haptic('success');
|
||||||
|
else haptic('impact');
|
||||||
|
shareBtn.textContent = copied ? '✅ Saved' : '💾 Saved';
|
||||||
|
setTimeout(() => {
|
||||||
|
shareBtn.textContent = '🔗 Share';
|
||||||
|
}, 1200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
feedEl.appendChild(card);
|
feedEl.appendChild(card);
|
||||||
furtokCards.push(card);
|
furtokCards.push(card);
|
||||||
});
|
});
|
||||||
|
|
@ -779,7 +956,10 @@ async function loadFurtokPage(feedEl) {
|
||||||
// Автоплей первого видео при первой загрузке
|
// Автоплей первого видео при первой загрузке
|
||||||
if (furtokCurrentIndex === 0 && furtokCards.length > 0) {
|
if (furtokCurrentIndex === 0 && furtokCards.length > 0) {
|
||||||
const firstVideo = furtokCards[0].querySelector('video');
|
const firstVideo = furtokCards[0].querySelector('video');
|
||||||
if (firstVideo) firstVideo.play().catch(() => {});
|
if (firstVideo) {
|
||||||
|
firstVideo.muted = true;
|
||||||
|
firstVideo.play().catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
haptic('success');
|
haptic('success');
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,9 @@ server {
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Telegram-Init-Data $http_x_telegram_init_data;
|
proxy_set_header X-Telegram-Init-Data $http_x_telegram_init_data;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
fastapi>=0.111.0
|
fastapi>=0.111.0
|
||||||
uvicorn[standard]>=0.29.0
|
uvicorn[standard]>=0.29.0
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
|
|
||||||
479
webapp/shorties.py
Normal file
479
webapp/shorties.py
Normal file
|
|
@ -0,0 +1,479 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PH_BASE_URL = "https://rt.pornhub.com"
|
||||||
|
SHORTIES_LIST_URL = f"{PH_BASE_URL}/shorties"
|
||||||
|
REQUEST_HEADERS = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||||
|
"Referer": PH_BASE_URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
_A_HREF_RE = re.compile(
|
||||||
|
r'<a[^>]+href="(?P<href>/view_video\.php\?viewkey=[^"]+)"(?P<attrs>[^>]*)>(?P<body>.*?)</a>',
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
_IMG_URL_RE = re.compile(
|
||||||
|
r'(?:data-mediumthumb|data-path|data-thumb_url|src)="(?P<url>https?://[^"]+)"',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_TITLE_ATTR_RE = re.compile(r'title="(?P<title>[^"]+)"', re.IGNORECASE)
|
||||||
|
_SCRIPT_LD_JSON_RE = re.compile(
|
||||||
|
r'<script[^>]+type="application/ld\+json"[^>]*>(?P<json>.*?)</script>',
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
_MEDIA_DEFS_RE = re.compile(r'"mediaDefinitions"\s*:\s*(\[[^\]]+\])', re.IGNORECASE | re.DOTALL)
|
||||||
|
_VIDEO_URL_RE = re.compile(r'"videoUrl"\s*:\s*"(?P<url>https?:\\?/\\?/[^"]+\.mp4[^"]*)"', re.IGNORECASE)
|
||||||
|
_DURATION_RE = re.compile(r'"video_duration"\s*:\s*"?(?P<duration>\d+)"?', re.IGNORECASE)
|
||||||
|
_VIEWS_RE = re.compile(r'"video_views"\s*:\s*"?(?P<views>[0-9,\.]+)"?', re.IGNORECASE)
|
||||||
|
_JSON_SHORTIES_MARKER = "JSON_SHORTIES = insertAfterNthPosition("
|
||||||
|
_EMBED_SRC_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
|
||||||
|
_CYRILLIC_RE = re.compile(r"[А-Яа-яЁё]")
|
||||||
|
_RUSSIAN_MARKERS = {
|
||||||
|
"russian",
|
||||||
|
"russia",
|
||||||
|
"russkiy",
|
||||||
|
"russkaya",
|
||||||
|
"russkoe",
|
||||||
|
"русский",
|
||||||
|
"русская",
|
||||||
|
"русское",
|
||||||
|
"россия",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ShortiesCandidate:
|
||||||
|
page_url: str
|
||||||
|
title: str
|
||||||
|
thumb_url: str
|
||||||
|
duration: str = ""
|
||||||
|
views: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_html_text(value: str) -> str:
|
||||||
|
no_tags = re.sub(r"<[^>]+>", " ", value)
|
||||||
|
return " ".join(html.unescape(no_tags).split())
|
||||||
|
|
||||||
|
|
||||||
|
def _absolute_url(value: str) -> str:
|
||||||
|
return urljoin(PH_BASE_URL, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_ldjson_candidates(page_html: str) -> list[ShortiesCandidate]:
|
||||||
|
out: list[ShortiesCandidate] = []
|
||||||
|
for match in _SCRIPT_LD_JSON_RE.finditer(page_html):
|
||||||
|
raw_json = html.unescape(match.group("json").strip())
|
||||||
|
if not raw_json:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
nodes: list[object]
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
nodes = parsed
|
||||||
|
else:
|
||||||
|
nodes = [parsed]
|
||||||
|
for node in nodes:
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
continue
|
||||||
|
if str(node.get("@type", "")).lower() != "videoobject":
|
||||||
|
continue
|
||||||
|
page_url = str(node.get("url") or "").strip()
|
||||||
|
thumb = str(node.get("thumbnailUrl") or "").strip()
|
||||||
|
title = str(node.get("name") or "").strip() or "Shorties"
|
||||||
|
if not page_url:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
ShortiesCandidate(
|
||||||
|
page_url=_absolute_url(page_url),
|
||||||
|
title=title,
|
||||||
|
thumb_url=_absolute_url(thumb) if thumb else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_anchor_candidates(page_html: str) -> list[ShortiesCandidate]:
|
||||||
|
out: list[ShortiesCandidate] = []
|
||||||
|
for match in _A_HREF_RE.finditer(page_html):
|
||||||
|
href = match.group("href").strip()
|
||||||
|
attrs = match.group("attrs") or ""
|
||||||
|
body = match.group("body") or ""
|
||||||
|
page_url = _absolute_url(href)
|
||||||
|
|
||||||
|
title_match = _TITLE_ATTR_RE.search(attrs) or _TITLE_ATTR_RE.search(body)
|
||||||
|
title = _clean_html_text(title_match.group("title")) if title_match else _clean_html_text(body)
|
||||||
|
if not title:
|
||||||
|
title = "Shorties"
|
||||||
|
|
||||||
|
thumb_match = _IMG_URL_RE.search(attrs) or _IMG_URL_RE.search(body)
|
||||||
|
thumb_url = _absolute_url(thumb_match.group("url")) if thumb_match else ""
|
||||||
|
|
||||||
|
out.append(ShortiesCandidate(page_url=page_url, title=title, thumb_url=thumb_url))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_candidates(items: list[ShortiesCandidate]) -> list[ShortiesCandidate]:
|
||||||
|
result: list[ShortiesCandidate] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in items:
|
||||||
|
key = item.page_url.strip()
|
||||||
|
if not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_text(session: aiohttp.ClientSession, url: str) -> str:
|
||||||
|
async with session.get(url, headers=REQUEST_HEADERS) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
logger.warning("Shorties request failed: %s status=%s", url, resp.status)
|
||||||
|
return ""
|
||||||
|
return await resp.text()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_best_mp4(page_html: str) -> str:
|
||||||
|
media_match = _MEDIA_DEFS_RE.search(page_html)
|
||||||
|
if media_match:
|
||||||
|
raw = media_match.group(1)
|
||||||
|
try:
|
||||||
|
defs = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
defs = []
|
||||||
|
best_url = ""
|
||||||
|
best_quality = -1
|
||||||
|
for item in defs:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
video_url = item.get("videoUrl")
|
||||||
|
if not isinstance(video_url, str) or ".mp4" not in video_url:
|
||||||
|
continue
|
||||||
|
quality_raw = str(item.get("quality") or "").strip("p ")
|
||||||
|
try:
|
||||||
|
quality = int(quality_raw)
|
||||||
|
except ValueError:
|
||||||
|
quality = 0
|
||||||
|
if quality >= best_quality:
|
||||||
|
best_quality = quality
|
||||||
|
best_url = video_url
|
||||||
|
if best_url:
|
||||||
|
return best_url.replace("\\/", "/")
|
||||||
|
|
||||||
|
for match in _VIDEO_URL_RE.finditer(page_html):
|
||||||
|
candidate = match.group("url").replace("\\/", "/")
|
||||||
|
if ".mp4" in candidate:
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_balanced_json_array(page_html: str, marker: str) -> str:
|
||||||
|
marker_idx = page_html.find(marker)
|
||||||
|
if marker_idx < 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
arr_start = page_html.find("[", marker_idx)
|
||||||
|
if arr_start < 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
depth = 0
|
||||||
|
in_string = False
|
||||||
|
escaped = False
|
||||||
|
for i in range(arr_start, len(page_html)):
|
||||||
|
ch = page_html[i]
|
||||||
|
if in_string:
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif ch == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif ch == '"':
|
||||||
|
in_string = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == '"':
|
||||||
|
in_string = True
|
||||||
|
continue
|
||||||
|
if ch == "[":
|
||||||
|
depth += 1
|
||||||
|
continue
|
||||||
|
if ch == "]":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return page_html[arr_start : i + 1]
|
||||||
|
continue
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_shorties(page_html: str) -> list[dict]:
|
||||||
|
raw_array = _extract_balanced_json_array(page_html, _JSON_SHORTIES_MARKER)
|
||||||
|
if not raw_array:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_array)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Shorties parser: failed to decode JSON_SHORTIES array")
|
||||||
|
return []
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
return []
|
||||||
|
return [item for item in parsed if isinstance(item, dict) and item.get("videoTitle")]
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_mp4_from_media_defs(media_defs: object) -> str:
|
||||||
|
if not isinstance(media_defs, list):
|
||||||
|
return ""
|
||||||
|
best_url = ""
|
||||||
|
best_quality = -1
|
||||||
|
for item in media_defs:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if str(item.get("format", "")).lower() != "mp4":
|
||||||
|
continue
|
||||||
|
raw_url = item.get("videoUrl")
|
||||||
|
if not isinstance(raw_url, str) or not raw_url:
|
||||||
|
continue
|
||||||
|
quality_raw = str(item.get("quality") or "").strip("p ")
|
||||||
|
try:
|
||||||
|
quality = int(quality_raw)
|
||||||
|
except ValueError:
|
||||||
|
quality = 0
|
||||||
|
if quality >= best_quality:
|
||||||
|
best_quality = quality
|
||||||
|
best_url = raw_url
|
||||||
|
return best_url.replace("\\/", "/")
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_hls_from_media_defs(media_defs: object) -> str:
|
||||||
|
if not isinstance(media_defs, list):
|
||||||
|
return ""
|
||||||
|
best_url = ""
|
||||||
|
best_quality = -1
|
||||||
|
for item in media_defs:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if str(item.get("format", "")).lower() != "hls":
|
||||||
|
continue
|
||||||
|
raw_url = item.get("videoUrl")
|
||||||
|
if not isinstance(raw_url, str) or ".m3u8" not in raw_url:
|
||||||
|
continue
|
||||||
|
quality_raw = str(item.get("quality") or "").strip("p ")
|
||||||
|
try:
|
||||||
|
quality = int(quality_raw)
|
||||||
|
except ValueError:
|
||||||
|
quality = 0
|
||||||
|
if quality >= best_quality:
|
||||||
|
best_quality = quality
|
||||||
|
best_url = raw_url
|
||||||
|
return best_url.replace("\\/", "/")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_russian_item(item: dict) -> bool:
|
||||||
|
title = str(item.get("videoTitle") or item.get("metaTitle") or "")
|
||||||
|
if _CYRILLIC_RE.search(title):
|
||||||
|
return True
|
||||||
|
|
||||||
|
pills = item.get("pillsData")
|
||||||
|
if isinstance(pills, list):
|
||||||
|
for pill in pills:
|
||||||
|
if not isinstance(pill, dict):
|
||||||
|
continue
|
||||||
|
values = [str(pill.get("name") or ""), str(pill.get("slug") or "")]
|
||||||
|
for raw in values:
|
||||||
|
normalized = raw.strip().lower()
|
||||||
|
if any(marker in normalized for marker in _RUSSIAN_MARKERS):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_russian_feed_item(item: dict) -> bool:
|
||||||
|
title = str(item.get("title") or "")
|
||||||
|
lowered = title.lower()
|
||||||
|
return bool(_CYRILLIC_RE.search(title) or any(marker in lowered for marker in _RUSSIAN_MARKERS))
|
||||||
|
|
||||||
|
|
||||||
|
def _feed_from_json_shorties(items: list[dict]) -> list[dict]:
|
||||||
|
feed: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in items:
|
||||||
|
if not _is_russian_item(item):
|
||||||
|
continue
|
||||||
|
hls_url = _pick_hls_from_media_defs(item.get("mediaDefinitions"))
|
||||||
|
mp4_url = _pick_mp4_from_media_defs(item.get("mediaDefinitions"))
|
||||||
|
if not hls_url and not mp4_url:
|
||||||
|
continue
|
||||||
|
source = str(item.get("linkUrl") or item.get("uniqueUrl") or item.get("shortieUrl") or "").strip()
|
||||||
|
if source and source.startswith("/"):
|
||||||
|
source = _absolute_url(source)
|
||||||
|
key = source or hls_url or mp4_url
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
|
||||||
|
tracking = item.get("trackingTimeWatched")
|
||||||
|
duration_raw = ""
|
||||||
|
if isinstance(tracking, dict):
|
||||||
|
duration_raw = str(tracking.get("video_duration") or "").strip()
|
||||||
|
title = str(item.get("videoTitle") or item.get("metaTitle") or "Shorties").strip()
|
||||||
|
thumb = str(item.get("imageUrl") or "").strip().replace("\\/", "/")
|
||||||
|
views = str(item.get("likeInfo") or item.get("likeNumber") or "").strip()
|
||||||
|
favorites = str(item.get("favoriteInfo") or item.get("favoriteNumber") or "").strip()
|
||||||
|
embed_url = ""
|
||||||
|
embed_raw = item.get("embedUrl")
|
||||||
|
if isinstance(embed_raw, str) and embed_raw.strip():
|
||||||
|
unescaped = html.unescape(embed_raw.replace("\\/", "/"))
|
||||||
|
match = _EMBED_SRC_RE.search(unescaped)
|
||||||
|
if match:
|
||||||
|
embed_url = match.group(1).strip()
|
||||||
|
|
||||||
|
feed.append(
|
||||||
|
{
|
||||||
|
"type": "video",
|
||||||
|
"url": hls_url or mp4_url,
|
||||||
|
"sample": thumb or hls_url or mp4_url,
|
||||||
|
"ext": "mp4",
|
||||||
|
"score": 0,
|
||||||
|
"fav_count": favorites or "0",
|
||||||
|
"title": title,
|
||||||
|
"duration": duration_raw,
|
||||||
|
"views": views,
|
||||||
|
"source": source or _absolute_url("/shorties"),
|
||||||
|
"hls_url": hls_url,
|
||||||
|
"mp4_url": mp4_url,
|
||||||
|
"embed_url": embed_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return feed
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_meta_fields(page_html: str) -> tuple[str, str]:
|
||||||
|
duration_match = _DURATION_RE.search(page_html)
|
||||||
|
views_match = _VIEWS_RE.search(page_html)
|
||||||
|
duration = duration_match.group("duration") if duration_match else ""
|
||||||
|
views = views_match.group("views") if views_match else ""
|
||||||
|
return duration, views
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_video_candidate(
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
item: ShortiesCandidate,
|
||||||
|
semaphore: asyncio.Semaphore,
|
||||||
|
) -> dict | None:
|
||||||
|
async with semaphore:
|
||||||
|
page_html = await _fetch_text(session, item.page_url)
|
||||||
|
if not page_html:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mp4_url = _extract_best_mp4(page_html)
|
||||||
|
if not mp4_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
duration, views = _extract_meta_fields(page_html)
|
||||||
|
if not item.duration:
|
||||||
|
item.duration = duration
|
||||||
|
if not item.views:
|
||||||
|
item.views = views
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "video",
|
||||||
|
"url": mp4_url,
|
||||||
|
"sample": item.thumb_url or mp4_url,
|
||||||
|
"ext": "mp4",
|
||||||
|
"score": 0,
|
||||||
|
"fav_count": 0,
|
||||||
|
"title": item.title,
|
||||||
|
"duration": item.duration or "",
|
||||||
|
"views": item.views or "",
|
||||||
|
"source": item.page_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_shorties_feed(count: int = 8, random_pages: int = 3) -> list[dict]:
|
||||||
|
count = max(1, min(count, 20))
|
||||||
|
random_pages = max(1, min(random_pages, 2))
|
||||||
|
|
||||||
|
page_numbers = {1}
|
||||||
|
while len(page_numbers) < random_pages:
|
||||||
|
page_numbers.add(random.randint(1, 40))
|
||||||
|
list_urls = [f"{SHORTIES_LIST_URL}?page={page}" for page in sorted(page_numbers)]
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=20)
|
||||||
|
connector = aiohttp.TCPConnector(limit=16)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
|
||||||
|
list_pages = await asyncio.gather(*[_fetch_text(session, url) for url in list_urls], return_exceptions=True)
|
||||||
|
|
||||||
|
json_shorties_feed: list[dict] = []
|
||||||
|
for page in list_pages:
|
||||||
|
if isinstance(page, Exception) or not page:
|
||||||
|
continue
|
||||||
|
shorties_items = _extract_json_shorties(page)
|
||||||
|
if not shorties_items:
|
||||||
|
continue
|
||||||
|
json_shorties_feed.extend(_feed_from_json_shorties(shorties_items))
|
||||||
|
|
||||||
|
if json_shorties_feed:
|
||||||
|
russian_only = [item for item in json_shorties_feed if _is_russian_feed_item(item)]
|
||||||
|
# Строго пытаемся отдать russian first; если пусто — отдаём локализованную rt-ленту.
|
||||||
|
selected_feed = russian_only if russian_only else json_shorties_feed
|
||||||
|
random.shuffle(selected_feed)
|
||||||
|
# dedupe by source/url after enrichment
|
||||||
|
dedup: list[dict] = []
|
||||||
|
seen_keys: set[str] = set()
|
||||||
|
for item in selected_feed:
|
||||||
|
key = str(item.get("source") or item.get("url") or "").strip()
|
||||||
|
if not key or key in seen_keys:
|
||||||
|
continue
|
||||||
|
seen_keys.add(key)
|
||||||
|
dedup.append(item)
|
||||||
|
return dedup[:count]
|
||||||
|
|
||||||
|
candidates: list[ShortiesCandidate] = []
|
||||||
|
for page in list_pages:
|
||||||
|
if isinstance(page, Exception) or not page:
|
||||||
|
continue
|
||||||
|
candidates.extend(_extract_ldjson_candidates(page))
|
||||||
|
candidates.extend(_extract_anchor_candidates(page))
|
||||||
|
|
||||||
|
candidates = _dedupe_candidates(candidates)
|
||||||
|
if not candidates:
|
||||||
|
logger.warning("Shorties parser: no candidates found")
|
||||||
|
return []
|
||||||
|
|
||||||
|
random.shuffle(candidates)
|
||||||
|
candidates = candidates[: max(count * 3, 12)]
|
||||||
|
|
||||||
|
semaphore = asyncio.Semaphore(5)
|
||||||
|
resolved = await asyncio.gather(
|
||||||
|
*[_resolve_video_candidate(session, item, semaphore) for item in candidates],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
feed: list[dict] = []
|
||||||
|
for item in resolved:
|
||||||
|
if isinstance(item, dict) and item.get("url"):
|
||||||
|
feed.append(item)
|
||||||
|
|
||||||
|
russian_only_fallback = [item for item in feed if _is_russian_feed_item(item)]
|
||||||
|
if russian_only_fallback:
|
||||||
|
feed = russian_only_fallback
|
||||||
|
random.shuffle(feed)
|
||||||
|
return feed[:count]
|
||||||
Loading…
Reference in a new issue