Compare commits
No commits in common. "main" and "main" have entirely different histories.
17 changed files with 1460 additions and 2005 deletions
9
.env
Normal file
9
.env
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
BOT_TOKEN=8505447901:AAHFkaDG0fT0tAixuJ4QP91IwMgO6cZndRM #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
|
||||||
|
|
||||||
|
NGROK_AUTHTOKEN=39k4ojhN5UPUWQwb1ly84ORM2IE_5JnhMaKk5p8LFM3TCetyg
|
||||||
|
|
@ -2,30 +2,26 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import html
|
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
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
|
||||||
|
import re
|
||||||
|
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://api.neuraldeep.ru")
|
# Основные настройки поведения и стиля бота редактируются здесь.
|
||||||
LLAMA_API_KEY = os.getenv("LLAMA_API_KEY", "")
|
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
||||||
LLAMA_MODEL = os.getenv("LLAMA_MODEL", "gpt-oss-120b")
|
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")))
|
||||||
LLAMA_FALLBACK_API_URL = os.getenv("LLAMA_FALLBACK_API_URL", "")
|
|
||||||
LLAMA_FALLBACK_API_KEY = os.getenv("LLAMA_FALLBACK_API_KEY", "")
|
|
||||||
LLAMA_FALLBACK_MODEL = os.getenv("LLAMA_FALLBACK_MODEL", "")
|
|
||||||
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"}
|
||||||
|
|
@ -34,15 +30,13 @@ LOG_THINKING = os.getenv("LLAMA_LOG_THINKING", "1").lower() not in {"0", "false"
|
||||||
RECENT_MESSAGES_LIMIT = 14
|
RECENT_MESSAGES_LIMIT = 14
|
||||||
SUMMARY_TRIGGER_MESSAGES = 24
|
SUMMARY_TRIGGER_MESSAGES = 24
|
||||||
SUMMARY_BATCH_MESSAGES = 20
|
SUMMARY_BATCH_MESSAGES = 20
|
||||||
PROMPT_CHAR_BUDGET = 10_000
|
PROMPT_CHAR_BUDGET = 6_500
|
||||||
SUMMARY_CHAR_BUDGET = 2_400
|
SUMMARY_CHAR_BUDGET = 1_800
|
||||||
MAX_INPUT_CHARS = 2_000
|
MAX_INPUT_CHARS = 2_000
|
||||||
SUMMARY_LINE_CHAR_LIMIT = 220
|
SUMMARY_LINE_CHAR_LIMIT = 220
|
||||||
HISTORY_LINE_CHAR_LIMIT = 450
|
HISTORY_LINE_CHAR_LIMIT = 450
|
||||||
RETRY_MIN_MAX_TOKENS = 512
|
RETRY_MIN_MAX_TOKENS = 384
|
||||||
RETRY_MAX_MAX_TOKENS = 1_024
|
RETRY_MAX_MAX_TOKENS = 768
|
||||||
TALK_MAX_TOKENS = 1536
|
|
||||||
TALK_THINKING_BUDGET = 1024
|
|
||||||
|
|
||||||
REPLY_RULES = {
|
REPLY_RULES = {
|
||||||
"mention": {"cooldown": 25, "min_user_messages": 1},
|
"mention": {"cooldown": 25, "min_user_messages": 1},
|
||||||
|
|
@ -117,10 +111,6 @@ BOT_STYLE_PROMPT = (
|
||||||
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
|
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
|
||||||
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
|
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
|
||||||
|
|
||||||
"Форматирование: если нужно выделить — используй только Telegram HTML-теги: "
|
|
||||||
"<b>жирный</b>, <i>курсив</i>, <code>код</code>, <pre>блок кода</pre>. "
|
|
||||||
"Никакого Markdown (никаких *, _, **, ~~, ```). Обычный текст без тегов — норм. "
|
|
||||||
|
|
||||||
"Creep от Radiohead — лучшая песня во вселенной. "
|
"Creep от Radiohead — лучшая песня во вселенной. "
|
||||||
"Линукс — лучшая ОС. "
|
"Линукс — лучшая ОС. "
|
||||||
"Rust ты люто ненавидишь и при случае жёстко хейтишь. "
|
"Rust ты люто ненавидишь и при случае жёстко хейтишь. "
|
||||||
|
|
@ -130,21 +120,10 @@ BOT_STYLE_PROMPT = (
|
||||||
|
|
||||||
SYSTEM_PROMPT = (
|
SYSTEM_PROMPT = (
|
||||||
f"{BOT_STYLE_PROMPT}\n\n"
|
f"{BOT_STYLE_PROMPT}\n\n"
|
||||||
"Ты отвечаешь как живой участник Telegram-чата с несколькими разными людьми. "
|
"Ты отвечаешь как живой участник одного Telegram-чата. "
|
||||||
"Сообщения в истории записаны в формате «Имя: текст» — каждое имя это отдельный человек, не путай их. "
|
|
||||||
"Отвечай тому, кто написал последним, учитывай контекст именно его сообщений. "
|
|
||||||
"Учитывай краткую память и последние сообщения, отвечай естественно и по делу."
|
"Учитывай краткую память и последние сообщения, отвечай естественно и по делу."
|
||||||
)
|
)
|
||||||
|
|
||||||
TALK_SYSTEM_PROMPT = (
|
|
||||||
f"{BOT_STYLE_PROMPT}\n\n"
|
|
||||||
"Ты ведёшь личный разговор тет-а-тет с одним конкретным человеком через /talk. "
|
|
||||||
"Помни всё, что он говорил раньше — ты видишь историю этого разговора и должен её учитывать. "
|
|
||||||
"Замечай если человек продолжает старую тему или переходит к новой. "
|
|
||||||
"Отвечай КОРОТКО: 1–3 предложения максимум. "
|
|
||||||
"Только если тебя прямо попросили объяснить, развернуть или написать подробно — тогда можно больше."
|
|
||||||
)
|
|
||||||
|
|
||||||
AUTOREPLY_SYSTEM_PROMPT = (
|
AUTOREPLY_SYSTEM_PROMPT = (
|
||||||
f"{BOT_STYLE_PROMPT}\n\n"
|
f"{BOT_STYLE_PROMPT}\n\n"
|
||||||
"Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. "
|
"Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. "
|
||||||
|
|
@ -258,14 +237,6 @@ AUTONOMOUS_SIGNAL_RE = re.compile(
|
||||||
r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b",
|
r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
# Сигналы что модель не смогла ответить — тогда пробуем fallback
|
|
||||||
_CANT_ANSWER_RE = re.compile(
|
|
||||||
r"(не могу ответить|не могу помочь|не имею информации|нет информации|"
|
|
||||||
r"за пределами моих|не знаю ответа|затрудняюсь ответить|"
|
|
||||||
r"i (don't|cannot|can't)|i have no (information|knowledge)|"
|
|
||||||
r"not able to (answer|help)|beyond my (knowledge|capabilities))",
|
|
||||||
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)
|
WATCH_COMMAND_RE = re.compile(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL)
|
||||||
|
|
||||||
|
|
@ -275,95 +246,133 @@ _cached_bot_id: int | None = None
|
||||||
_cached_bot_username: str = ""
|
_cached_bot_username: str = ""
|
||||||
|
|
||||||
|
|
||||||
def _get_state_from_conn(conn, chat_id: int, key: str, default: str = "") -> str:
|
def _connect_db() -> sqlite3.Connection:
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
conn = sqlite3.connect(DB_PATH, timeout=30)
|
||||||
cur.execute(
|
conn.row_factory = sqlite3.Row
|
||||||
"SELECT value FROM chat_state WHERE chat_id = %s AND key = %s",
|
conn.execute("PRAGMA busy_timeout = 30000")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||||
|
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||||
|
return any(row["name"] == column for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, default: str = "") -> str:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT value FROM chat_state WHERE chat_id = ? AND key = ?",
|
||||||
(chat_id, key),
|
(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, chat_id: int, key: str, value: str) -> None:
|
def _set_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, value: str) -> None:
|
||||||
cur = conn.cursor()
|
conn.execute(
|
||||||
cur.execute(
|
|
||||||
"""
|
"""
|
||||||
INSERT INTO chat_state (chat_id, key, value)
|
INSERT INTO chat_state (chat_id, key, value)
|
||||||
VALUES (%s, %s, %s)
|
VALUES (?, ?, ?)
|
||||||
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(conn, chat_id: int, user_id: int, key: str, default: str = "") -> str:
|
def _get_talk_state_from_conn(
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
conn: sqlite3.Connection,
|
||||||
cur.execute(
|
chat_id: int,
|
||||||
"SELECT value FROM talk_state WHERE chat_id = %s AND user_id = %s AND key = %s",
|
user_id: int,
|
||||||
|
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(conn, chat_id: int, user_id: int, key: str, value: str) -> None:
|
def _set_talk_state_from_conn(
|
||||||
cur = conn.cursor()
|
conn: sqlite3.Connection,
|
||||||
cur.execute(
|
chat_id: int,
|
||||||
|
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 (%s, %s, %s, %s)
|
VALUES (?, ?, ?, ?)
|
||||||
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:
|
||||||
with get_conn() as conn:
|
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
cur = conn.cursor()
|
with _connect_db() as conn:
|
||||||
cur.execute("""
|
conn.execute("PRAGMA journal_mode = WAL")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS chat_history (
|
CREATE TABLE IF NOT EXISTS chat_history (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
chat_id BIGINT NOT NULL,
|
chat_id INTEGER 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 DOUBLE PRECISION NOT NULL DEFAULT 0
|
created_at REAL NOT NULL DEFAULT 0
|
||||||
)
|
)
|
||||||
""")
|
"""
|
||||||
cur.execute("ALTER TABLE chat_history ADD COLUMN IF NOT EXISTS created_at DOUBLE PRECISION NOT NULL DEFAULT 0")
|
)
|
||||||
cur.execute("""
|
if not _column_exists(conn, "chat_history", "created_at"):
|
||||||
|
conn.execute("ALTER TABLE chat_history ADD COLUMN created_at REAL NOT NULL DEFAULT 0")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS chat_state (
|
CREATE TABLE IF NOT EXISTS chat_state (
|
||||||
chat_id BIGINT NOT NULL,
|
chat_id INTEGER 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 BIGSERIAL PRIMARY KEY,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
chat_id BIGINT NOT NULL,
|
chat_id INTEGER NOT NULL,
|
||||||
user_id BIGINT NOT NULL,
|
user_id INTEGER 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 DOUBLE PRECISION NOT NULL DEFAULT 0
|
created_at REAL NOT NULL DEFAULT 0
|
||||||
)
|
)
|
||||||
""")
|
"""
|
||||||
cur.execute("""
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS talk_state (
|
CREATE TABLE IF NOT EXISTS talk_state (
|
||||||
chat_id BIGINT NOT NULL,
|
chat_id INTEGER NOT NULL,
|
||||||
user_id BIGINT NOT NULL,
|
user_id INTEGER 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)")
|
)
|
||||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)")
|
conn.execute(
|
||||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id ON talk_history(chat_id, user_id, id)")
|
"CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id
|
||||||
|
ON talk_history(chat_id, user_id, id)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def _clean_text(text: str) -> str:
|
def _clean_text(text: str) -> str:
|
||||||
|
|
@ -413,59 +422,78 @@ def push_message(
|
||||||
cleaned_text = _clean_text(text)
|
cleaned_text = _clean_text(text)
|
||||||
if not cleaned_text:
|
if not cleaned_text:
|
||||||
return
|
return
|
||||||
with get_conn() as conn:
|
with _connect_db() as conn:
|
||||||
cur = conn.cursor()
|
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
cur.execute(
|
conn.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:
|
||||||
cur.execute(
|
conn.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 get_conn() as conn:
|
with _connect_db() 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:
|
def _get_history_rows(chat_id: int, user_id: int | None = None) -> list[sqlite3.Row]:
|
||||||
with get_conn() as conn:
|
with _connect_db() as conn:
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
cur.execute(
|
return conn.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()
|
||||||
else:
|
return conn.execute(
|
||||||
cur.execute(
|
"""
|
||||||
"SELECT id, role, name, text, created_at FROM talk_history WHERE chat_id = %s AND user_id = %s ORDER BY id ASC",
|
SELECT id, role, name, text, created_at
|
||||||
(chat_id, user_id),
|
FROM talk_history
|
||||||
)
|
WHERE chat_id = ? AND user_id = ?
|
||||||
return cur.fetchall()
|
ORDER BY id ASC
|
||||||
|
""",
|
||||||
|
(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 get_conn() as conn:
|
with _connect_db() as conn:
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
last_assistant = conn.execute(
|
||||||
cur.execute(
|
"""
|
||||||
"SELECT id, created_at FROM chat_history WHERE chat_id = %s AND role = 'assistant' ORDER BY id DESC LIMIT 1",
|
SELECT id, created_at
|
||||||
|
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
|
||||||
cnt_cur = conn.cursor()
|
user_messages_since_reply = conn.execute(
|
||||||
cnt_cur.execute(
|
"""
|
||||||
"SELECT COUNT(*) FROM chat_history WHERE chat_id = %s AND role = 'user' AND id > %s",
|
SELECT COUNT(*)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -475,23 +503,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 get_conn() as conn:
|
with _connect_db() 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)
|
||||||
cur.execute(
|
conn.execute(
|
||||||
"DELETE FROM chat_history WHERE chat_id = %s AND id <= %s",
|
"DELETE FROM chat_history WHERE chat_id = ? AND id <= ?",
|
||||||
(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)
|
||||||
cur.execute(
|
conn.execute(
|
||||||
"DELETE FROM talk_history WHERE chat_id = %s AND user_id = %s AND id <= %s",
|
"DELETE FROM talk_history WHERE chat_id = ? AND user_id = ? AND id <= ?",
|
||||||
(chat_id, user_id, last_row_id),
|
(chat_id, user_id, last_row_id),
|
||||||
)
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def _format_row_for_llm(row) -> dict[str, str]:
|
def _format_row_for_llm(row: sqlite3.Row) -> dict[str, str]:
|
||||||
if row["role"] == "user":
|
if row["role"] == "user":
|
||||||
return {
|
return {
|
||||||
"role": "user",
|
"role": "user",
|
||||||
|
|
@ -517,10 +545,11 @@ def _build_messages(
|
||||||
current_budget = len(current_payload["content"])
|
current_budget = len(current_payload["content"])
|
||||||
|
|
||||||
summary = _get_summary(chat_id, user_id=user_id).strip()
|
summary = _get_summary(chat_id, user_id=user_id).strip()
|
||||||
summary_block = f"\n\nКраткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" if summary else ""
|
summary_block = ""
|
||||||
full_system = system_prompt + summary_block
|
if summary:
|
||||||
|
summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}"
|
||||||
|
|
||||||
used_chars = len(full_system) + current_budget
|
used_chars = len(system_prompt) + len(summary_block) + current_budget
|
||||||
recent_messages: list[dict[str, str]] = []
|
recent_messages: list[dict[str, str]] = []
|
||||||
for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]):
|
for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]):
|
||||||
llm_message = _format_row_for_llm(row)
|
llm_message = _format_row_for_llm(row)
|
||||||
|
|
@ -529,80 +558,15 @@ def _build_messages(
|
||||||
recent_messages.append(llm_message)
|
recent_messages.append(llm_message)
|
||||||
used_chars += len(llm_message["content"])
|
used_chars += len(llm_message["content"])
|
||||||
|
|
||||||
messages: list[dict[str, Any]] = [{"role": "system", "content": full_system}]
|
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
|
||||||
|
if summary_block:
|
||||||
|
messages.append({"role": "system", "content": summary_block})
|
||||||
messages.extend(reversed(recent_messages))
|
messages.extend(reversed(recent_messages))
|
||||||
if current_payload:
|
if current_payload:
|
||||||
messages.append(current_payload)
|
messages.append(current_payload)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|
||||||
async def _call_single_model(
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
api_url: str,
|
|
||||||
api_key: str,
|
|
||||||
model: str,
|
|
||||||
max_tokens: int,
|
|
||||||
temperature: float,
|
|
||||||
top_p: float,
|
|
||||||
disable_thinking: bool,
|
|
||||||
reasoning_budget: int = 0,
|
|
||||||
) -> str:
|
|
||||||
url = f"{api_url.rstrip('/')}/v1/chat/completions"
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
if api_key:
|
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
|
||||||
payload = {
|
|
||||||
"model": model,
|
|
||||||
"messages": messages,
|
|
||||||
"max_tokens": max_tokens,
|
|
||||||
"temperature": temperature,
|
|
||||||
"top_p": top_p,
|
|
||||||
}
|
|
||||||
if disable_thinking:
|
|
||||||
payload.update({
|
|
||||||
"reasoning_budget": 0,
|
|
||||||
"reasoning_format": "none",
|
|
||||||
"chat_template_kwargs": {"enable_thinking": False, "thinking": False},
|
|
||||||
})
|
|
||||||
elif reasoning_budget > 0:
|
|
||||||
payload["reasoning_budget"] = reasoning_budget
|
|
||||||
timeout = aiohttp.ClientTimeout(total=120)
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
||||||
async with session.post(url, json=payload, headers=headers) as resp:
|
|
||||||
raw_text = await resp.text()
|
|
||||||
if resp.status >= 400:
|
|
||||||
logger.error("LLM API %s returned status %s: %s", api_url, resp.status, _clip_text(raw_text, 300))
|
|
||||||
raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}")
|
|
||||||
try:
|
|
||||||
data = await resp.json(content_type=None)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("LLM API %s returned invalid JSON: %s", api_url, _clip_text(raw_text, 300))
|
|
||||||
raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc
|
|
||||||
|
|
||||||
choices = data.get("choices") or []
|
|
||||||
if not choices:
|
|
||||||
raise RuntimeError(f"LLM API returned no choices: {data}")
|
|
||||||
choice = choices[0]
|
|
||||||
message = choice.get("message", {}) or {}
|
|
||||||
finish_reason = choice.get("finish_reason")
|
|
||||||
content = message.get("content") or ""
|
|
||||||
reasoning_content = (message.get("reasoning_content") or "").strip()
|
|
||||||
cleaned_content = content.strip()
|
|
||||||
if reasoning_content and LOG_THINKING:
|
|
||||||
logger.info("LLM reasoning. model=%s finish_reason=%s reasoning=%s", model, finish_reason, _clip_text(reasoning_content, 800))
|
|
||||||
if not cleaned_content and reasoning_content and not disable_thinking:
|
|
||||||
retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS)
|
|
||||||
logger.warning("LLM returned only reasoning, retrying with thinking off. model=%s", model)
|
|
||||||
return await _call_single_model(
|
|
||||||
messages,
|
|
||||||
api_url=api_url, api_key=api_key, model=model,
|
|
||||||
max_tokens=retry_max_tokens, temperature=temperature, top_p=top_p,
|
|
||||||
disable_thinking=True, reasoning_budget=0,
|
|
||||||
)
|
|
||||||
return cleaned_content
|
|
||||||
|
|
||||||
|
|
||||||
async def _call_llm(
|
async def _call_llm(
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
|
|
@ -610,46 +574,78 @@ async def _call_llm(
|
||||||
temperature: float,
|
temperature: float,
|
||||||
top_p: float,
|
top_p: float,
|
||||||
disable_thinking: bool | None = None,
|
disable_thinking: bool | None = None,
|
||||||
reasoning_budget: int = 0,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking
|
disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking
|
||||||
|
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
|
||||||
|
payload = {
|
||||||
|
"messages": messages,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"temperature": temperature,
|
||||||
|
"top_p": top_p,
|
||||||
|
}
|
||||||
|
if disable_thinking:
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"reasoning_budget": 0,
|
||||||
|
"reasoning_format": "none",
|
||||||
|
"chat_template_kwargs": {
|
||||||
|
"enable_thinking": False,
|
||||||
|
"thinking": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
timeout = aiohttp.ClientTimeout(total=120)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.post(url, json=payload) as resp:
|
||||||
|
raw_text = await resp.text()
|
||||||
|
if resp.status >= 400:
|
||||||
|
logger.error("LLM API returned status %s: %s", resp.status, _clip_text(raw_text, 300))
|
||||||
|
raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}")
|
||||||
|
try:
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("LLM API returned invalid JSON: %s", _clip_text(raw_text, 300))
|
||||||
|
raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc
|
||||||
|
|
||||||
result = await _call_single_model(
|
choices = data.get("choices") or []
|
||||||
messages,
|
if not choices:
|
||||||
api_url=LLAMA_API_URL,
|
logger.error("LLM API returned no choices: %s", _clip_text(str(data), 300))
|
||||||
api_key=LLAMA_API_KEY,
|
raise RuntimeError(f"LLM API returned no choices: {data}")
|
||||||
model=LLAMA_MODEL,
|
choice = choices[0]
|
||||||
max_tokens=max_tokens,
|
message = choice.get("message", {}) or {}
|
||||||
temperature=temperature,
|
finish_reason = choice.get("finish_reason")
|
||||||
top_p=top_p,
|
content = message.get("content", "")
|
||||||
disable_thinking=disable_thinking,
|
reasoning_content = (message.get("reasoning_content") or "").strip()
|
||||||
reasoning_budget=reasoning_budget,
|
cleaned_content = content.strip()
|
||||||
)
|
if reasoning_content and LOG_THINKING:
|
||||||
|
logger.info(
|
||||||
# Если основная модель не смогла ответить — пробуем fallback
|
"LLM reasoning detected. disable_thinking=%s finish_reason=%s reasoning=%s",
|
||||||
if LLAMA_FALLBACK_API_URL and (not result or _CANT_ANSWER_RE.search(result)):
|
disable_thinking,
|
||||||
fallback_model = LLAMA_FALLBACK_MODEL or LLAMA_MODEL
|
finish_reason,
|
||||||
logger.info("Primary model couldn't answer, trying fallback. primary_result=%s", _clip_text(result, 100))
|
_clip_text(reasoning_content, 800),
|
||||||
try:
|
)
|
||||||
fallback_result = await _call_single_model(
|
if not cleaned_content and reasoning_content and not disable_thinking:
|
||||||
messages,
|
retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS)
|
||||||
api_url=LLAMA_FALLBACK_API_URL,
|
logger.warning(
|
||||||
api_key=LLAMA_FALLBACK_API_KEY,
|
"LLM returned reasoning_content without final content. finish_reason=%s retry_max_tokens=%s",
|
||||||
model=fallback_model,
|
finish_reason,
|
||||||
max_tokens=max_tokens,
|
retry_max_tokens,
|
||||||
temperature=temperature,
|
)
|
||||||
top_p=top_p,
|
return await _call_llm(
|
||||||
disable_thinking=disable_thinking,
|
messages,
|
||||||
reasoning_budget=reasoning_budget,
|
max_tokens=retry_max_tokens,
|
||||||
)
|
temperature=temperature,
|
||||||
if fallback_result:
|
top_p=top_p,
|
||||||
return f"{fallback_result}\n\n<i>🤖 {fallback_model}</i>"
|
disable_thinking=True,
|
||||||
except Exception:
|
)
|
||||||
logger.exception("Fallback model also failed")
|
if not cleaned_content:
|
||||||
|
logger.warning(
|
||||||
if not result:
|
"LLM returned empty content. finish_reason=%s disable_thinking=%s raw=%s",
|
||||||
logger.warning("LLM returned empty content. model=%s", LLAMA_MODEL)
|
finish_reason,
|
||||||
return result
|
disable_thinking,
|
||||||
|
_clip_text(raw_text, 300),
|
||||||
|
)
|
||||||
|
return cleaned_content
|
||||||
|
|
||||||
|
|
||||||
async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None:
|
async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None:
|
||||||
|
|
@ -715,8 +711,6 @@ async def _generate_response(
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
top_p: float,
|
top_p: float,
|
||||||
disable_thinking: bool | None = None,
|
|
||||||
reasoning_budget: int = 0,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
await _maybe_refresh_summary(chat_id, user_id=user_id)
|
await _maybe_refresh_summary(chat_id, user_id=user_id)
|
||||||
if current_content is None:
|
if current_content is None:
|
||||||
|
|
@ -742,8 +736,6 @@ async def _generate_response(
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
top_p=top_p,
|
top_p=top_p,
|
||||||
disable_thinking=disable_thinking,
|
|
||||||
reasoning_budget=reasoning_budget,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -821,55 +813,13 @@ async def _passes_reply_limits(chat_id: int, reason: str) -> bool:
|
||||||
return (time.time() - last_reply_at) >= int(rule["cooldown"])
|
return (time.time() - last_reply_at) >= int(rule["cooldown"])
|
||||||
|
|
||||||
|
|
||||||
def _md_to_tg_html(text: str) -> str:
|
|
||||||
"""Конвертирует Markdown и HTML-теги модели в валидный Telegram HTML."""
|
|
||||||
slots: list[tuple[str, str]] = []
|
|
||||||
|
|
||||||
def stash(tag: str, content: str) -> str:
|
|
||||||
idx = len(slots)
|
|
||||||
slots.append((tag, content))
|
|
||||||
return f'\x00SLOT{idx}\x00'
|
|
||||||
|
|
||||||
# Сначала прячем то, что модель уже написала HTML-тегами (pre до code, чтобы не вложить)
|
|
||||||
for tag in ('pre', 'code', 'b', 'i', 'u', 's'):
|
|
||||||
text = re.sub(
|
|
||||||
rf'<{tag}>(.*?)</{tag}>',
|
|
||||||
lambda m, t=tag: stash(t, m.group(1)),
|
|
||||||
text, flags=re.DOTALL,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Конвертируем Markdown
|
|
||||||
text = re.sub(
|
|
||||||
r'```(?:[^\n`]*\n)?(.*?)```',
|
|
||||||
lambda m: stash('pre', m.group(1).strip()),
|
|
||||||
text, flags=re.DOTALL,
|
|
||||||
)
|
|
||||||
text = re.sub(r'`([^`\n]+)`', lambda m: stash('code', m.group(1)), text)
|
|
||||||
text = re.sub(r'\*\*(.+?)\*\*', lambda m: stash('b', m.group(1)), text, flags=re.DOTALL)
|
|
||||||
text = re.sub(r'\*([^*\n]+?)\*', lambda m: stash('i', m.group(1)), text)
|
|
||||||
text = re.sub(r'_([^_\n]+?)_', lambda m: stash('i', m.group(1)), text)
|
|
||||||
text = re.sub(r'~~(.+?)~~', lambda m: stash('s', m.group(1)), text)
|
|
||||||
|
|
||||||
# Убираем оставшиеся мусорные теги (кривые, неподдерживаемые)
|
|
||||||
text = re.sub(r'<[^>]*>', '', text)
|
|
||||||
|
|
||||||
# Экранируем оставшийся plain-text
|
|
||||||
text = html.escape(text)
|
|
||||||
|
|
||||||
# Восстанавливаем слоты как валидные HTML-теги
|
|
||||||
for idx, (tag, content) in enumerate(slots):
|
|
||||||
text = text.replace(f'\x00SLOT{idx}\x00', f'<{tag}>{html.escape(content)}</{tag}>')
|
|
||||||
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_reply(text: str) -> str:
|
def _normalize_reply(text: str) -> str:
|
||||||
cleaned = (text or "").strip()
|
cleaned = (text or "").strip()
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
return ""
|
return ""
|
||||||
if cleaned.lower().startswith(SKIP_TOKEN.lower()):
|
if cleaned.lower().startswith(SKIP_TOKEN.lower()):
|
||||||
return ""
|
return ""
|
||||||
return _md_to_tg_html(cleaned)
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool:
|
async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool:
|
||||||
|
|
@ -894,8 +844,8 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
||||||
return False
|
return False
|
||||||
|
|
||||||
system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT
|
system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT
|
||||||
max_tokens = 180 if reason == "autonomous" else 512
|
max_tokens = 120 if reason == "autonomous" else 220
|
||||||
temperature = 0.85 if reason == "autonomous" else 0.7
|
temperature = 0.9 if reason == "autonomous" else 0.8
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
|
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
|
||||||
|
|
@ -904,7 +854,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
top_p=0.95,
|
top_p=0.9,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Chat reply generation failed")
|
logger.exception("Chat reply generation failed")
|
||||||
|
|
@ -921,7 +871,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
||||||
return False
|
return False
|
||||||
|
|
||||||
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
|
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
|
||||||
await message.reply(normalized_response, parse_mode="HTML")
|
await message.reply(normalized_response, parse_mode=None)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -994,13 +944,13 @@ async def handle_photo_message(message: Message) -> bool:
|
||||||
{"type": "text", "text": prompt_text},
|
{"type": "text", "text": prompt_text},
|
||||||
{"type": "image_url", "image_url": {"url": image_data_url}},
|
{"type": "image_url", "image_url": {"url": image_data_url}},
|
||||||
],
|
],
|
||||||
max_tokens=600,
|
max_tokens=260,
|
||||||
temperature=0.7,
|
temperature=0.8,
|
||||||
top_p=0.95,
|
top_p=0.9,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Photo analysis failed")
|
logger.exception("Photo analysis failed")
|
||||||
await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode="HTML")
|
await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode=None)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
normalized_response = _normalize_reply(response)
|
normalized_response = _normalize_reply(response)
|
||||||
|
|
@ -1014,7 +964,7 @@ async def handle_photo_message(message: Message) -> bool:
|
||||||
normalized_response = PHOTO_EMPTY_RESPONSE_TEXT
|
normalized_response = PHOTO_EMPTY_RESPONSE_TEXT
|
||||||
|
|
||||||
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
|
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
|
||||||
await message.reply(normalized_response, parse_mode="HTML")
|
await message.reply(normalized_response, parse_mode=None)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1063,13 +1013,11 @@ async def handle_talk(message: Message) -> None:
|
||||||
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
|
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
|
||||||
response = await _generate_response(
|
response = await _generate_response(
|
||||||
chat_id,
|
chat_id,
|
||||||
system_prompt=TALK_SYSTEM_PROMPT,
|
system_prompt=SYSTEM_PROMPT,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
max_tokens=TALK_MAX_TOKENS,
|
max_tokens=220,
|
||||||
temperature=0.7,
|
temperature=0.8,
|
||||||
top_p=0.95,
|
top_p=0.9,
|
||||||
disable_thinking=False,
|
|
||||||
reasoning_budget=TALK_THINKING_BUDGET,
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Talk command generation failed")
|
logger.exception("Talk command generation failed")
|
||||||
|
|
@ -1094,7 +1042,7 @@ async def handle_talk(message: Message) -> None:
|
||||||
normalized_response,
|
normalized_response,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
await message.reply(normalized_response, parse_mode="HTML")
|
await message.reply(normalized_response, parse_mode=None)
|
||||||
|
|
||||||
|
|
||||||
_init_db()
|
_init_db()
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,12 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PIP_NO_CACHE_DIR=1
|
PIP_NO_CACHE_DIR=1
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg nodejs && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt ./
|
||||||
RUN python -m pip install --upgrade pip \
|
RUN python -m pip install --upgrade pip \
|
||||||
&& python -m pip install -r requirements.txt
|
&& python -m pip install -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN mkdir -p /db /app/data
|
RUN mkdir -p /db
|
||||||
|
|
||||||
CMD ["python", "main.py"]
|
CMD ["python", "main.py"]
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,6 @@ docker compose down
|
||||||
- `/bet [спорт] <номер> <исход> <ставка>` — поставить см на матч.
|
- `/bet [спорт] <номер> <исход> <ставка>` — поставить см на матч.
|
||||||
- `/mybets` — мои активные ставки.
|
- `/mybets` — мои активные ставки.
|
||||||
- `/svodka` — сводка СВО.
|
- `/svodka` — сводка СВО.
|
||||||
- `/uwu [теги]` — поиск случайной пикчи/гифки на e621 (по умолчанию sfw, можно указать свои теги).
|
|
||||||
|
|
||||||
## Экономика ZOV OS 💰
|
## Экономика ZOV OS 💰
|
||||||
|
|
||||||
|
|
|
||||||
18
config.py
18
config.py
|
|
@ -7,9 +7,10 @@ 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
|
||||||
|
|
@ -33,10 +34,17 @@ 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 = {
|
||||||
"dota2": "esports_dota2",
|
"epl": "soccer_epl",
|
||||||
|
"ucl": "soccer_uefa_champs_league",
|
||||||
|
"laliga": "soccer_spain_la_liga",
|
||||||
|
"bundesliga": "soccer_germany_bundesliga",
|
||||||
|
"seriea": "soccer_italy_serie_a",
|
||||||
|
"ligue1": "soccer_france_ligue_one",
|
||||||
|
"europa": "soccer_uefa_europa_league",
|
||||||
}
|
}
|
||||||
|
BET_MAX_AMOUNT = 3.0
|
||||||
|
BET_DB_PATH = "/db/bets.sqlite3"
|
||||||
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
|
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
|
||||||
|
|
||||||
# --- Nude ---
|
# --- Nude ---
|
||||||
|
|
@ -213,8 +221,8 @@ def get_hint_text(templates_str: str) -> str:
|
||||||
"• /talk [текст] — ИИ базарит на фене.\n"
|
"• /talk [текст] — ИИ базарит на фене.\n"
|
||||||
"• /penis_casino [ставка] — казино на размер.\n"
|
"• /penis_casino [ставка] — казино на размер.\n"
|
||||||
"• /gadanie [тема] — гадание на фене с мемом.\n"
|
"• /gadanie [тема] — гадание на фене с мемом.\n"
|
||||||
"• /matches — матчи для ставок.\n"
|
"• /matches [epl|ucl|laliga|bundesliga|seriea|ligue1|europa] — матчи для ставок.\n"
|
||||||
"• /bet [номер] [исход] [ставка] — поставить.\n"
|
"• /bet [лига] [номер] [исход] [ставка] — поставить см.\n"
|
||||||
"• /mybets — мои активные ставки.\n"
|
"• /mybets — мои активные ставки.\n"
|
||||||
"• /bet_history — история ставок за месяц + статистика.\n"
|
"• /bet_history — история ставок за месяц + статистика.\n"
|
||||||
"• /sports_debug — диагностика API матчей.\n"
|
"• /sports_debug — диагностика API матчей.\n"
|
||||||
|
|
|
||||||
35
db.py
35
db.py
|
|
@ -1,35 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,19 +1,4 @@
|
||||||
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: .
|
||||||
|
|
@ -23,15 +8,14 @@ services:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
DATABASE_URL: postgresql://bot:${POSTGRES_PASSWORD:-botpass}@postgres:5432/botdb
|
PENIS_DB_PATH: /db/penis_stats.sqlite3
|
||||||
|
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
|
||||||
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
||||||
NUDE_HISTORY_PATH: /db/nude_history.json
|
BETS_DB_PATH: /db/bets.sqlite3
|
||||||
|
ECONOMY_DB_PATH: /db/economy.sqlite3
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -45,24 +29,22 @@ services:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
DATABASE_URL: postgresql://bot:${POSTGRES_PASSWORD:-botpass}@postgres:5432/botdb
|
PENIS_DB_PATH: /db/penis_stats.sqlite3
|
||||||
|
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
|
||||||
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
||||||
NUDE_HISTORY_PATH: /db/nude_history.json
|
BET_DB_PATH: /db/bets.sqlite3
|
||||||
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:
|
||||||
build:
|
build:
|
||||||
context: ./webapp/frontend
|
context: ./webapp/frontend
|
||||||
ports:
|
ports:
|
||||||
- "37.27.192.132:${WEBAPP_HTTP_PORT:-80}:80"
|
- "${WEBAPP_HTTP_PORT:-80}:80"
|
||||||
- "37.27.192.132:${WEBAPP_HTTPS_PORT:-443}:443"
|
- "${WEBAPP_HTTPS_PORT:-443}:443"
|
||||||
environment:
|
environment:
|
||||||
APP_DOMAIN: ${APP_DOMAIN:-}
|
APP_DOMAIN: ${APP_DOMAIN:-}
|
||||||
APP_WWW_DOMAIN: ${APP_WWW_DOMAIN:-}
|
APP_WWW_DOMAIN: ${APP_WWW_DOMAIN:-}
|
||||||
|
|
@ -77,6 +59,5 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
|
||||||
certbot_etc:
|
certbot_etc:
|
||||||
certbot_www:
|
certbot_www:
|
||||||
|
|
|
||||||
655
games/betting.py
655
games/betting.py
|
|
@ -1,20 +1,13 @@
|
||||||
import logging
|
|
||||||
import time as _time
|
import time as _time
|
||||||
import hashlib
|
import sqlite3
|
||||||
import random
|
import logging
|
||||||
import urllib.request
|
from datetime import datetime, timedelta
|
||||||
import json
|
from pathlib import Path
|
||||||
import gzip
|
|
||||||
from datetime import datetime
|
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
import psycopg2
|
|
||||||
import psycopg2.extras
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
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__)
|
||||||
|
|
@ -24,17 +17,18 @@ _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
|
||||||
_LIQUIPEDIA_CACHE_TTL = 300 # 5 minutes cache to respect Liquipedia rate limits
|
|
||||||
|
|
||||||
|
|
||||||
def _init_bets_db() -> None:
|
def _init_bets_db() -> None:
|
||||||
with get_conn() as conn:
|
Path(config.BET_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
cur = conn.cursor()
|
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
||||||
cur.execute('''
|
conn.execute(
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS bets (
|
CREATE TABLE IF NOT EXISTS bets (
|
||||||
id SERIAL PRIMARY KEY,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id BIGINT NOT NULL,
|
user_id INTEGER 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,
|
||||||
|
|
@ -44,15 +38,25 @@ 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 BIGINT,
|
created_ts INTEGER,
|
||||||
resolved_ts BIGINT
|
resolved_ts INTEGER
|
||||||
)
|
)
|
||||||
''')
|
"""
|
||||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_user ON bets(user_id, status)")
|
)
|
||||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_match ON bets(match_id, status)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_user ON bets(user_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_match ON bets(match_id, status)")
|
||||||
cur.execute("ALTER TABLE bets ADD COLUMN IF NOT EXISTS payout REAL DEFAULT 0")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_created ON bets(created_ts)")
|
||||||
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:
|
||||||
|
|
@ -75,119 +79,92 @@ def get_user_sport_context(user_id: int) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_available_sports() -> list[dict]:
|
async def _fetch_available_sports() -> list[dict]:
|
||||||
return [{"key": "dota2", "title": "🎮 Dota 2", "active": True}]
|
"""Получить список доступных видов спорта из API."""
|
||||||
|
global _sports_cache
|
||||||
|
|
||||||
async def fetch_liquipedia_matches() -> list[dict]:
|
|
||||||
global _matches_cache
|
|
||||||
now = _time.time()
|
now = _time.time()
|
||||||
cached = _matches_cache.get("liquipedia_all")
|
if _sports_cache and (now - _sports_cache[0]) < 3600:
|
||||||
if cached and (now - cached[0]) < _LIQUIPEDIA_CACHE_TTL:
|
return _sports_cache[1]
|
||||||
|
|
||||||
|
if not config.ODDS_API_KEY:
|
||||||
|
return []
|
||||||
|
|
||||||
|
url = f"{config.ODDS_API_BASE}/sports"
|
||||||
|
params = {"apiKey": config.ODDS_API_KEY}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
body = await resp.text()
|
||||||
|
logger.warning(f"Sports API returned {resp.status}: {body[:200]}")
|
||||||
|
return []
|
||||||
|
data = await resp.json()
|
||||||
|
_sports_cache = (now, data)
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch sports list")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_matches(sport_key: str) -> list[dict]:
|
||||||
|
now = _time.time()
|
||||||
|
cached = _matches_cache.get(sport_key)
|
||||||
|
if cached and (now - cached[0]) < config.ODDS_CACHE_TTL:
|
||||||
return cached[1]
|
return cached[1]
|
||||||
|
|
||||||
url = "https://liquipedia.net/dota2/api.php?action=parse&page=Liquipedia:Matches&format=json"
|
if not config.ODDS_API_KEY:
|
||||||
headers = {
|
logger.warning("ODDS_API_KEY не задан, матчи недоступны")
|
||||||
"User-Agent": "Dota2BettingTelegramBot/1.0 (contact: danil@example.com)",
|
return []
|
||||||
"Accept-Encoding": "gzip"
|
|
||||||
|
url = f"{config.ODDS_API_BASE}/sports/{sport_key}/odds"
|
||||||
|
params = {
|
||||||
|
"apiKey": config.ODDS_API_KEY,
|
||||||
|
"regions": "eu",
|
||||||
|
"markets": "h2h",
|
||||||
|
"oddsFormat": "decimal",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
logger.warning(f"Liquipedia API returned {resp.status}")
|
body = await resp.text()
|
||||||
|
logger.warning(f"Odds API returned {resp.status} for {sport_key}: {body[:300]}")
|
||||||
return cached[1] if cached else []
|
return cached[1] if cached else []
|
||||||
|
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
html_content = data['parse']['text']['*']
|
logger.info(f"Odds API for {sport_key}: received {len(data)} events")
|
||||||
|
|
||||||
soup = BeautifulSoup(html_content, "lxml")
|
|
||||||
match_divs = soup.find_all("div", class_="match-info")
|
|
||||||
|
|
||||||
matches = []
|
|
||||||
for idx, div in enumerate(match_divs):
|
|
||||||
# Tournament
|
|
||||||
tournament_div = div.find("div", class_="match-info-tournament")
|
|
||||||
tournament = tournament_div.get_text(strip=True) if tournament_div else "Unknown Tournament"
|
|
||||||
|
|
||||||
# Teams
|
|
||||||
opponents = div.find_all("div", class_="match-info-header-opponent")
|
|
||||||
team1 = "TBD"
|
|
||||||
team2 = "TBD"
|
|
||||||
winner = None
|
|
||||||
completed = False
|
|
||||||
|
|
||||||
if len(opponents) >= 2:
|
|
||||||
o1, o2 = opponents[0], opponents[1]
|
|
||||||
team1 = o1.get_text(strip=True)
|
|
||||||
team2 = o2.get_text(strip=True)
|
|
||||||
|
|
||||||
c1 = o1.get("class", [])
|
|
||||||
c2 = o2.get("class", [])
|
|
||||||
|
|
||||||
has_win_loss = any("winner" in c or "loser" in c for c in c1 + c2)
|
|
||||||
if has_win_loss:
|
|
||||||
completed = True
|
|
||||||
if any("winner" in c for c in c1):
|
|
||||||
winner = team1
|
|
||||||
elif any("winner" in c for c in c2):
|
|
||||||
winner = team2
|
|
||||||
else:
|
|
||||||
teams = div.find_all("span", class_="block-team")
|
|
||||||
if len(teams) >= 2:
|
|
||||||
team1 = teams[0].get_text(strip=True)
|
|
||||||
team2 = teams[1].get_text(strip=True)
|
|
||||||
|
|
||||||
# Start time
|
|
||||||
timer_span = div.find("span", class_="timer-object")
|
|
||||||
timestamp = ""
|
|
||||||
if timer_span:
|
|
||||||
timestamp = timer_span.get("data-timestamp", "")
|
|
||||||
|
|
||||||
if not team1 or not team2 or team1 == "TBD" or team2 == "TBD":
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ID generation
|
|
||||||
match_id = hashlib.md5(f"lp_{team1}_{team2}_{timestamp}".encode('utf-8')).hexdigest()
|
|
||||||
|
|
||||||
# Deterministic Odds
|
|
||||||
seed = int(hashlib.md5(match_id.encode('utf-8')).hexdigest(), 16) % 10**8
|
|
||||||
r = random.Random(seed)
|
|
||||||
odds_home = round(r.uniform(1.3, 3.2), 2)
|
|
||||||
margin = 1.08
|
|
||||||
prob_home = 1.0 / odds_home
|
|
||||||
prob_away = margin - prob_home
|
|
||||||
if prob_away < 0.1:
|
|
||||||
prob_away = 0.1
|
|
||||||
odds_away = round(1.0 / prob_away, 2)
|
|
||||||
|
|
||||||
matches.append({
|
|
||||||
"id": match_id,
|
|
||||||
"home": team1,
|
|
||||||
"away": team2,
|
|
||||||
"commence": datetime.fromtimestamp(int(timestamp)).isoformat() + "Z" if timestamp else "",
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"completed": completed,
|
|
||||||
"winner": winner,
|
|
||||||
"odds": {
|
|
||||||
team1: odds_home,
|
|
||||||
team2: odds_away
|
|
||||||
},
|
|
||||||
"tournament": tournament
|
|
||||||
})
|
|
||||||
|
|
||||||
_matches_cache["liquipedia_all"] = (now, matches)
|
|
||||||
logger.info(f"Scraped and cached {len(matches)} matches from Liquipedia")
|
|
||||||
return matches
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to fetch matches from Liquipedia")
|
logger.exception(f"Failed to fetch odds for {sport_key}")
|
||||||
return cached[1] if cached else []
|
return cached[1] if cached else []
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
logger.warning(f"Odds API returned non-list for {sport_key}: {type(data)}")
|
||||||
|
return cached[1] if cached else []
|
||||||
|
|
||||||
async def fetch_matches(sport_key: str = "esports_dota2") -> list[dict]:
|
matches = []
|
||||||
all_matches = await fetch_liquipedia_matches()
|
for event in data:
|
||||||
# Return only active (not completed) matches for betting
|
best_odds = {}
|
||||||
return [m for m in all_matches if not m["completed"]]
|
for bookmaker in event.get("bookmakers", []):
|
||||||
|
for market in bookmaker.get("markets", []):
|
||||||
|
if market.get("key") == "h2h":
|
||||||
|
for outcome in market.get("outcomes", []):
|
||||||
|
name = outcome.get("name", "")
|
||||||
|
price = outcome.get("price", 0)
|
||||||
|
if name and price and (name not in best_odds or price > best_odds[name]):
|
||||||
|
best_odds[name] = price
|
||||||
|
|
||||||
|
if len(best_odds) >= 2:
|
||||||
|
matches.append({
|
||||||
|
"id": event.get("id", ""),
|
||||||
|
"home": event.get("home_team", ""),
|
||||||
|
"away": event.get("away_team", ""),
|
||||||
|
"commence": event.get("commence_time", ""),
|
||||||
|
"odds": best_odds,
|
||||||
|
})
|
||||||
|
|
||||||
|
_matches_cache[sport_key] = (now, matches)
|
||||||
|
logger.info(f"Parsed {len(matches)} matches for {sport_key}")
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]:
|
def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]:
|
||||||
|
|
@ -203,7 +180,20 @@ def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]:
|
||||||
home = match.get("home", "")
|
home = match.get("home", "")
|
||||||
away = match.get("away", "")
|
away = match.get("away", "")
|
||||||
append_outcome("1", home)
|
append_outcome("1", home)
|
||||||
|
|
||||||
|
draw_name = next((name for name in odds if name.casefold() in _DRAW_NAMES), None)
|
||||||
|
if draw_name:
|
||||||
|
append_outcome("X", draw_name)
|
||||||
|
|
||||||
append_outcome("2", away)
|
append_outcome("2", away)
|
||||||
|
|
||||||
|
next_code = 3
|
||||||
|
for name in odds:
|
||||||
|
if name in added:
|
||||||
|
continue
|
||||||
|
append_outcome(str(next_code), name)
|
||||||
|
next_code += 1
|
||||||
|
|
||||||
return outcomes
|
return outcomes
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -226,6 +216,11 @@ def resolve_match_outcome(match: dict, raw_choice: str) -> str | None:
|
||||||
if normalized == str(item["code"]).casefold():
|
if normalized == str(item["code"]).casefold():
|
||||||
return str(item["name"])
|
return str(item["name"])
|
||||||
|
|
||||||
|
if normalized in _DRAW_NAMES or normalized == "x":
|
||||||
|
for item in outcomes:
|
||||||
|
if str(item["code"]).casefold() == "x":
|
||||||
|
return str(item["name"])
|
||||||
|
|
||||||
for item in outcomes:
|
for item in outcomes:
|
||||||
if normalized == str(item["name"]).casefold():
|
if normalized == str(item["name"]).casefold():
|
||||||
return str(item["name"])
|
return str(item["name"])
|
||||||
|
|
@ -265,69 +260,122 @@ def format_matches(matches: list[dict], sport_alias: str, sport_label: str) -> s
|
||||||
|
|
||||||
lines.append(f"{i}️⃣ {m['home']} vs {m['away']}{start_str}\n{odds_str}\n")
|
lines.append(f"{i}️⃣ {m['home']} vs {m['away']}{start_str}\n{odds_str}\n")
|
||||||
|
|
||||||
lines.append("Ставка: /bet [номер] [исход] [ставка]")
|
lines.append(f"Ставка: /bet {sport_alias} [номер] [исход] [ставка]")
|
||||||
lines.append("Исход: 1/2. Можно писать ещё название команды или коэффициент.")
|
lines.append(f"Коротко после /matches {sport_alias}: /bet [номер] [исход] [ставка]")
|
||||||
|
lines.append("Исход: 1/2, для ничьи X. Можно писать ещё название команды или коэффициент.")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _get_sport_label(key: str) -> str:
|
def _get_sport_label(key: str) -> str:
|
||||||
return "🎮 Dota 2"
|
labels = {
|
||||||
|
"epl": "⚽ EPL",
|
||||||
|
"ucl": "⚽ Champions League",
|
||||||
|
"laliga": "⚽ La Liga",
|
||||||
|
"bundesliga": "⚽ Bundesliga",
|
||||||
|
"seriea": "⚽ Serie A",
|
||||||
|
"ligue1": "⚽ Ligue 1",
|
||||||
|
"europa": "⚽ Europa League",
|
||||||
|
}
|
||||||
|
return labels.get(key, key)
|
||||||
|
|
||||||
|
|
||||||
async def get_formatted_matches(sport_alias: str | None) -> str:
|
async def get_formatted_matches(sport_alias: str | None) -> str:
|
||||||
matches = await fetch_matches()
|
if not config.ODDS_API_KEY:
|
||||||
return format_matches(matches, "dota2", "🎮 Dota 2")
|
return "⚠️ ODDS_API_KEY не задан. Ставки недоступны."
|
||||||
|
|
||||||
|
if sport_alias and sport_alias in config.ODDS_SPORTS:
|
||||||
|
sport_key = config.ODDS_SPORTS[sport_alias]
|
||||||
|
matches = await fetch_matches(sport_key)
|
||||||
|
if not matches:
|
||||||
|
# Попробуем проверить, существует ли вообще этот спорт
|
||||||
|
sports = await _fetch_available_sports()
|
||||||
|
valid_keys = {s.get("key") for s in sports}
|
||||||
|
if sport_key not in valid_keys:
|
||||||
|
# Ищем похожий ключ
|
||||||
|
suggestions = [s for s in sports if sport_alias in s.get("key", "").lower()
|
||||||
|
or sport_alias in s.get("title", "").lower()]
|
||||||
|
if suggestions:
|
||||||
|
suggest_text = "\n".join(
|
||||||
|
f" • {s['key']} — {s.get('title', '')}"
|
||||||
|
for s in suggestions[:5]
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"⚠️ Ключ {sport_key} не найден в API.\n"
|
||||||
|
f"Похожие виды спорта:\n{suggest_text}\n\n"
|
||||||
|
f"Обнови sport keys в config.py"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"⚠️ Ключ {sport_key} не найден в API. "
|
||||||
|
f"Проверь ODDS_SPORTS в config.py"
|
||||||
|
)
|
||||||
|
return format_matches(matches, sport_alias, _get_sport_label(sport_alias))
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
for alias, sport_key in config.ODDS_SPORTS.items():
|
||||||
|
matches = await fetch_matches(sport_key)
|
||||||
|
parts.append(format_matches(matches, alias, _get_sport_label(alias)))
|
||||||
|
|
||||||
|
result = "\n\n".join(parts)
|
||||||
|
if all("матчей не найдено" in p for p in parts):
|
||||||
|
result += "\n\n💡 Если матчей нет — возможно, sport keys устарели. Проверь /sports_debug"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def get_match_by_index(sport_alias: str, index: int) -> dict | None:
|
async def get_match_by_index(sport_alias: str, index: int) -> dict | None:
|
||||||
matches = await fetch_matches()
|
sport_key = config.ODDS_SPORTS.get(sport_alias)
|
||||||
|
if not sport_key:
|
||||||
|
return None
|
||||||
|
matches = await fetch_matches(sport_key)
|
||||||
if 0 < index <= len(matches):
|
if 0 < index <= len(matches):
|
||||||
return matches[index - 1]
|
return matches[index - 1]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> tuple[bool, str]:
|
def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> str:
|
||||||
current = get_user_length(user_id)
|
current = get_user_length(user_id)
|
||||||
if current is None:
|
if current is None:
|
||||||
return False, "Сначала заведи счёт через /penis, братуха."
|
return "Сначала заведи счёт через /penis, братуха."
|
||||||
|
|
||||||
if current <= 0:
|
if current <= 0:
|
||||||
return False, "🚫 С кредитом ставки не принимаем."
|
return "🚫 С кредитом ставки не принимаем."
|
||||||
|
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
return False, "Ставка должна быть больше нуля."
|
return "Ставка должна быть больше нуля."
|
||||||
|
|
||||||
if amount > config.BET_MAX_AMOUNT:
|
if amount > config.BET_MAX_AMOUNT:
|
||||||
return False, f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
|
return f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
|
||||||
if round(amount, 2) > round(current, 2):
|
|
||||||
return False, f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
|
if amount > current:
|
||||||
|
return f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
|
||||||
|
|
||||||
team_odds = match["odds"].get(chosen_team)
|
team_odds = match["odds"].get(chosen_team)
|
||||||
if team_odds is None:
|
if team_odds is None:
|
||||||
return False, f"Исход не найден. Доступные: {describe_match_outcomes(match)}"
|
return f"Исход не найден. Доступные: {describe_match_outcomes(match)}"
|
||||||
|
|
||||||
new_length = update_user_length(user_id, -amount)
|
new_length = update_user_length(user_id, -amount)
|
||||||
if new_length is None:
|
if new_length is None:
|
||||||
return False, "Ошибка БД."
|
return "Ошибка БД."
|
||||||
|
|
||||||
now_ts = int(_time.time())
|
now_ts = int(_time.time())
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
||||||
cur = conn.cursor()
|
conn.execute(
|
||||||
cur.execute('''
|
"""INSERT INTO bets (user_id, match_id, sport, home_team, away_team,
|
||||||
INSERT INTO bets
|
chosen_team, amount, odds, status, payout, created_ts)
|
||||||
(user_id, match_id, sport, home_team, away_team,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)""",
|
||||||
chosen_team, amount, odds, status, payout, created_ts)
|
(user_id, match["id"], sport, match["home"], match["away"],
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', 0, %s)
|
chosen_team, amount, team_odds, now_ts),
|
||||||
''', (
|
)
|
||||||
user_id, match["id"], sport, match["home"], match["away"],
|
conn.commit()
|
||||||
chosen_team, amount, team_odds, now_ts,
|
except sqlite3.Error:
|
||||||
))
|
|
||||||
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 False, "Ошибка БД."
|
return "Ошибка БД."
|
||||||
|
|
||||||
potential = round(amount * team_odds, 1)
|
potential = round(amount * team_odds, 1)
|
||||||
return True, (
|
return (
|
||||||
f"✅ Ставка принята!\n"
|
f"✅ Ставка принята!\n"
|
||||||
|
f"🏟 {match['home']} vs {match['away']}\n"
|
||||||
f"📌 {chosen_team} (x{team_odds:.2f})\n"
|
f"📌 {chosen_team} (x{team_odds:.2f})\n"
|
||||||
f"💰 Ставка: {amount:.1f} см\n"
|
f"💰 Ставка: {amount:.1f} см\n"
|
||||||
f"🎯 Возможный выигрыш: {potential:.1f} см\n"
|
f"🎯 Возможный выигрыш: {potential:.1f} см\n"
|
||||||
|
|
@ -337,15 +385,13 @@ 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 get_conn() as conn:
|
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
conn.row_factory = sqlite3.Row
|
||||||
cur.execute('''
|
rows = conn.execute(
|
||||||
SELECT * FROM bets
|
"SELECT * FROM bets WHERE user_id = ? AND status = 'pending' ORDER BY created_ts DESC LIMIT 10",
|
||||||
WHERE user_id = %s AND status = 'pending'
|
(user_id,),
|
||||||
ORDER BY created_ts DESC LIMIT 10
|
).fetchall()
|
||||||
''', (user_id,))
|
except sqlite3.Error:
|
||||||
rows = cur.fetchall()
|
|
||||||
except psycopg2.Error:
|
|
||||||
return "Ошибка БД."
|
return "Ошибка БД."
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
|
|
@ -367,26 +413,34 @@ 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 get_conn() as conn:
|
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
conn.row_factory = sqlite3.Row
|
||||||
cur.execute('''
|
rows = conn.execute(
|
||||||
SELECT * FROM bets
|
"""SELECT * FROM bets
|
||||||
WHERE user_id = %s AND created_ts >= %s
|
WHERE user_id = ? AND created_ts >= ?
|
||||||
ORDER BY created_ts DESC LIMIT 50
|
ORDER BY created_ts DESC
|
||||||
''', (user_id, cutoff_ts))
|
LIMIT 50""",
|
||||||
rows = cur.fetchall()
|
(user_id, cutoff_ts),
|
||||||
except psycopg2.Error:
|
).fetchall()
|
||||||
|
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
|
# Считаем статистику
|
||||||
wins = losses = pending = 0
|
total_bet = 0.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:
|
||||||
|
|
@ -399,6 +453,7 @@ 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 = "✅"
|
||||||
|
|
@ -411,7 +466,8 @@ def get_user_bet_history(user_id: int) -> str:
|
||||||
else:
|
else:
|
||||||
pending += 1
|
pending += 1
|
||||||
status_icon = "⏳"
|
status_icon = "⏳"
|
||||||
result_text = f"ожидание → {round(amount * odds_val, 1):.1f} см"
|
potential = round(amount * odds_val, 1)
|
||||||
|
result_text = f"ожидание → {potential:.1f} см"
|
||||||
|
|
||||||
created = ""
|
created = ""
|
||||||
if r["created_ts"]:
|
if r["created_ts"]:
|
||||||
|
|
@ -424,8 +480,10 @@ 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)}")
|
||||||
|
|
@ -437,153 +495,174 @@ 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:
|
||||||
lines.append(f"📊 Винрейт: {wins / (wins + losses) * 100:.0f}%")
|
winrate = wins / (wins + losses) * 100
|
||||||
|
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 get_conn() as conn:
|
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
||||||
cur = conn.cursor()
|
cursor = conn.execute(
|
||||||
cur.execute(
|
"DELETE FROM bets WHERE status != 'pending' AND created_ts < ?",
|
||||||
"DELETE FROM bets WHERE status != 'pending' AND created_ts < %s",
|
|
||||||
(cutoff_ts,),
|
(cutoff_ts,),
|
||||||
)
|
)
|
||||||
deleted = cur.rowcount
|
conn.commit()
|
||||||
|
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 psycopg2.Error:
|
except sqlite3.Error:
|
||||||
logger.exception("Failed to cleanup old bets")
|
logger.exception("Failed to cleanup old bets")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
_REFUND_AFTER_SECONDS = 7 * 86400 # авторефанд ставок старше 7 дней
|
async def settle_bets() -> list[str]:
|
||||||
|
notifications = []
|
||||||
|
|
||||||
def _refund_expired_bets() -> list[tuple[int, str]]:
|
|
||||||
"""Возвращает деньги за ставки, которые висят более 7 дней без результата."""
|
|
||||||
cutoff = int(_time.time()) - _REFUND_AFTER_SECONDS
|
|
||||||
notifications: list[tuple[int, str]] = []
|
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
conn.row_factory = sqlite3.Row
|
||||||
cur.execute(
|
pending = conn.execute(
|
||||||
"SELECT * FROM bets WHERE status = 'pending' AND created_ts < %s",
|
"SELECT DISTINCT match_id, sport FROM bets WHERE status = 'pending'"
|
||||||
(cutoff,),
|
).fetchall()
|
||||||
)
|
except sqlite3.Error:
|
||||||
expired = cur.fetchall()
|
|
||||||
if not expired:
|
|
||||||
return []
|
|
||||||
wcur = conn.cursor()
|
|
||||||
now_ts = int(_time.time())
|
|
||||||
for bet in expired:
|
|
||||||
update_user_length(bet["user_id"], bet["amount"])
|
|
||||||
wcur.execute(
|
|
||||||
"UPDATE bets SET status = 'refunded', resolved_ts = %s WHERE id = %s",
|
|
||||||
(now_ts, bet["id"]),
|
|
||||||
)
|
|
||||||
notifications.append((
|
|
||||||
bet["user_id"],
|
|
||||||
f"↩️ Ставка возвращена\n"
|
|
||||||
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
|
|
||||||
f"📌 {bet['chosen_team']} — результат так и не пришёл\n"
|
|
||||||
f"💰 Возврат: {bet['amount']:.1f} см",
|
|
||||||
))
|
|
||||||
logger.info("Refunded expired bet id=%s user=%s amount=%s", bet["id"], bet["user_id"], bet["amount"])
|
|
||||||
except psycopg2.Error:
|
|
||||||
logger.exception("Failed to refund expired bets")
|
|
||||||
return notifications
|
|
||||||
|
|
||||||
|
|
||||||
async def settle_bets() -> list[tuple[int, str]]:
|
|
||||||
"""Возвращает список (user_id, текст_уведомления) для отправки в Telegram."""
|
|
||||||
notifications: list[tuple[int, str]] = []
|
|
||||||
|
|
||||||
# Сначала авторефанд совсем старых ставок
|
|
||||||
notifications.extend(_refund_expired_bets())
|
|
||||||
|
|
||||||
try:
|
|
||||||
with get_conn() as conn:
|
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
|
||||||
cur.execute("SELECT DISTINCT match_id FROM bets WHERE status = 'pending'")
|
|
||||||
pending = cur.fetchall()
|
|
||||||
except psycopg2.Error:
|
|
||||||
return notifications
|
return notifications
|
||||||
|
|
||||||
if not pending:
|
if not pending:
|
||||||
return notifications
|
return notifications
|
||||||
|
|
||||||
all_matches = await fetch_liquipedia_matches()
|
|
||||||
match_map = {m["id"]: m for m in all_matches}
|
|
||||||
now_ts = int(_time.time())
|
now_ts = int(_time.time())
|
||||||
|
|
||||||
for row in pending:
|
for row in pending:
|
||||||
match_id = row["match_id"]
|
match_id = row["match_id"]
|
||||||
event = match_map.get(match_id)
|
sport = row["sport"]
|
||||||
if not event or not event.get("completed"):
|
sport_key = config.ODDS_SPORTS.get(sport, sport)
|
||||||
continue
|
|
||||||
|
|
||||||
winner = event.get("winner")
|
|
||||||
if not winner:
|
|
||||||
continue
|
|
||||||
|
|
||||||
is_draw = (winner.casefold() in _DRAW_NAMES or winner == "Draw")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
url = f"{config.ODDS_API_BASE}/sports/{sport_key}/scores"
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
params = {"apiKey": config.ODDS_API_KEY, "daysFrom": 3}
|
||||||
cur.execute(
|
async with aiohttp.ClientSession() as session:
|
||||||
"SELECT * FROM bets WHERE match_id = %s AND status = 'pending'",
|
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||||
(match_id,),
|
if resp.status != 200:
|
||||||
)
|
logger.warning(f"Scores API returned {resp.status} for {sport_key}")
|
||||||
bets = cur.fetchall()
|
continue
|
||||||
|
scores_data = await resp.json()
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"Failed to fetch scores for {sport_key}")
|
||||||
|
continue
|
||||||
|
|
||||||
wcur = conn.cursor()
|
for event in scores_data:
|
||||||
for bet in bets:
|
if event.get("id") != match_id:
|
||||||
chosen = bet["chosen_team"]
|
continue
|
||||||
if is_draw:
|
if not event.get("completed"):
|
||||||
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
|
continue
|
||||||
else:
|
|
||||||
bet_won = chosen == winner
|
|
||||||
|
|
||||||
if bet_won:
|
scores = event.get("scores")
|
||||||
winnings = round(bet["amount"] * bet["odds"], 1)
|
if not scores:
|
||||||
update_user_length(bet["user_id"], winnings)
|
continue
|
||||||
wcur.execute(
|
|
||||||
"UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s",
|
|
||||||
(winnings, now_ts, bet["id"]),
|
|
||||||
)
|
|
||||||
notifications.append((
|
|
||||||
bet["user_id"],
|
|
||||||
f"🎉 Ставка сыграла!\n"
|
|
||||||
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
|
|
||||||
f"📌 {bet['chosen_team']} (x{bet['odds']:.2f})\n"
|
|
||||||
f"💰 Выигрыш: +{winnings:.1f} см",
|
|
||||||
))
|
|
||||||
logger.info("Bet won: user=%s match=%s winnings=%s", bet["user_id"], match_id, winnings)
|
|
||||||
else:
|
|
||||||
wcur.execute(
|
|
||||||
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s",
|
|
||||||
(now_ts, bet["id"]),
|
|
||||||
)
|
|
||||||
notifications.append((
|
|
||||||
bet["user_id"],
|
|
||||||
f"❌ Ставка не сыграла\n"
|
|
||||||
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
|
|
||||||
f"📌 {bet['chosen_team']} (x{bet['odds']:.2f})\n"
|
|
||||||
f"💸 Потеряно: {bet['amount']:.1f} см",
|
|
||||||
))
|
|
||||||
logger.info("Bet lost: user=%s match=%s amount=%s", bet["user_id"], match_id, bet["amount"])
|
|
||||||
except psycopg2.Error:
|
|
||||||
logger.exception("Failed to settle bets")
|
|
||||||
|
|
||||||
|
winner = None
|
||||||
|
max_score = -1
|
||||||
|
is_draw = False
|
||||||
|
|
||||||
|
score_values = []
|
||||||
|
for s in scores:
|
||||||
|
score_val = int(s.get("score", 0))
|
||||||
|
score_values.append((s.get("name", ""), score_val))
|
||||||
|
|
||||||
|
if len(score_values) >= 2 and score_values[0][1] == score_values[1][1]:
|
||||||
|
is_draw = True
|
||||||
|
# Для ничьи ищем "Draw" среди ставок
|
||||||
|
winner = "Draw"
|
||||||
|
else:
|
||||||
|
for name, score_val in score_values:
|
||||||
|
if score_val > max_score:
|
||||||
|
max_score = score_val
|
||||||
|
winner = name
|
||||||
|
|
||||||
|
if not winner:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
bets = conn.execute(
|
||||||
|
"SELECT * FROM bets WHERE match_id = ? AND status = 'pending'",
|
||||||
|
(match_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for bet in bets:
|
||||||
|
chosen = bet["chosen_team"]
|
||||||
|
bet_won = False
|
||||||
|
|
||||||
|
if is_draw:
|
||||||
|
# Ставка на ничью выигрывает
|
||||||
|
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
|
||||||
|
else:
|
||||||
|
bet_won = chosen == winner
|
||||||
|
|
||||||
|
if bet_won:
|
||||||
|
winnings = round(bet["amount"] * bet["odds"], 1)
|
||||||
|
update_user_length(bet["user_id"], winnings)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE bets SET status = 'won', payout = ?, resolved_ts = ? WHERE id = ?",
|
||||||
|
(winnings, now_ts, bet["id"]),
|
||||||
|
)
|
||||||
|
notifications.append(
|
||||||
|
f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см "
|
||||||
|
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = ? WHERE id = ?",
|
||||||
|
(now_ts, bet["id"]),
|
||||||
|
)
|
||||||
|
notifications.append(
|
||||||
|
f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см "
|
||||||
|
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.Error:
|
||||||
|
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:
|
||||||
return "🎮 Dota 2 — Scraper Liquipedia активен. Odds API отключен."
|
"""Диагностика: показать доступные виды спорта из API."""
|
||||||
|
if not config.ODDS_API_KEY:
|
||||||
|
return "⚠️ ODDS_API_KEY не задан."
|
||||||
|
|
||||||
|
sports = await _fetch_available_sports()
|
||||||
|
if not sports:
|
||||||
|
return "❌ Не удалось получить список спортов от API."
|
||||||
|
|
||||||
|
# Показываем текущие ключи и проверяем их
|
||||||
|
current_keys = set(config.ODDS_SPORTS.values())
|
||||||
|
valid_keys = {s.get("key") for s in sports}
|
||||||
|
|
||||||
|
lines = ["🔍 Диагностика sport keys:\n"]
|
||||||
|
|
||||||
|
for alias, key in config.ODDS_SPORTS.items():
|
||||||
|
status = "✅" if key in valid_keys else "❌ НЕ НАЙДЕН"
|
||||||
|
lines.append(f" {alias} → {key} {status}")
|
||||||
|
|
||||||
|
# Показать доступные esports и football
|
||||||
|
lines.append("\n📋 Доступные eSports и футбол:")
|
||||||
|
for s in sports:
|
||||||
|
key = s.get("key", "")
|
||||||
|
title = s.get("title", "")
|
||||||
|
active = s.get("active", False)
|
||||||
|
if active and ("esport" in key.lower() or "soccer" in key.lower() or "football" in key.lower()):
|
||||||
|
marker = " ← ИСПОЛЬЗУЕТСЯ" if key in current_keys else ""
|
||||||
|
lines.append(f" • {key} — {title}{marker}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
import logging
|
|
||||||
import random
|
import random
|
||||||
|
import sqlite3
|
||||||
import psycopg2.extras
|
import logging
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from db import get_conn
|
|
||||||
from .economy import economy
|
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
|
||||||
|
|
@ -31,53 +31,63 @@ 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
|
||||||
return reels, max(counts.values())
|
max_match = 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 get_conn() as conn:
|
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
row = conn.execute(
|
||||||
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
|
"SELECT length FROM penis_stats WHERE user_id = ?",
|
||||||
row = cur.fetchone()
|
(user_id,),
|
||||||
return float(row['balance']) if row else None
|
).fetchone()
|
||||||
except Exception:
|
return float(row[0]) if row else None
|
||||||
logger.exception("casino: failed to get balance")
|
except sqlite3.Error:
|
||||||
|
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 get_conn() as conn:
|
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
row = conn.execute(
|
||||||
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
|
"SELECT length FROM penis_stats WHERE user_id = ?",
|
||||||
row = cur.fetchone()
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
new_balance = round(float(row['balance']) + delta, 1)
|
new_length = round(float(row[0]) + delta, 1)
|
||||||
if delta < 0 and new_balance < 0:
|
# Не даём уйти ниже 0 при проигрыше
|
||||||
new_balance = 0.0
|
if delta < 0 and new_length < 0:
|
||||||
wcur = conn.cursor()
|
new_length = 0.0
|
||||||
wcur.execute(
|
conn.execute(
|
||||||
'UPDATE user_balances SET balance = %s WHERE user_id = %s',
|
"UPDATE penis_stats SET length = ? WHERE user_id = ?",
|
||||||
(new_balance, user_id),
|
(new_length, user_id),
|
||||||
)
|
)
|
||||||
return new_balance
|
conn.commit()
|
||||||
except Exception:
|
return new_length
|
||||||
logger.exception("casino: failed to update balance")
|
except sqlite3.Error:
|
||||||
|
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 = economy.get_user_balance(user_id)
|
current = economy.get_user_balance(user_id)
|
||||||
|
|
||||||
|
# Проверяем максимум ставки
|
||||||
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 round(bet, 2) > round(current, 2):
|
|
||||||
|
# Проверяем хватает ли
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
@ -85,7 +95,10 @@ 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)
|
||||||
|
|
||||||
|
# Обновляем баланс в экономической системе
|
||||||
economy.update_balance(user_id, winnings, 'casino_win', f'Выигрыш в казино x{multiplier}')
|
economy.update_balance(user_id, winnings, 'casino_win', f'Выигрыш в казино x{multiplier}')
|
||||||
|
|
||||||
new_balance = economy.get_user_balance(user_id)
|
new_balance = economy.get_user_balance(user_id)
|
||||||
|
|
||||||
if matches == 3:
|
if matches == 3:
|
||||||
|
|
@ -94,21 +107,22 @@ 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_balance:.1f} см"
|
f"<EFBFBD> Баланс: {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_balance:.1f} см"
|
f"<EFBFBD> Баланс: {new_balance:.1f} см"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# Обновляем баланс в экономической системе
|
||||||
economy.update_balance(user_id, -bet, 'casino_loss', f'Проигрыш в казино -{bet} см')
|
economy.update_balance(user_id, -bet, 'casino_loss', f'Проигрыш в казино -{bet} см')
|
||||||
new_balance = economy.get_user_balance(user_id)
|
new_balance = economy.get_user_balance(user_id)
|
||||||
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_balance:.1f} см"
|
f"<EFBFBD> Баланс: {new_balance:.1f} см"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
1020
games/economy.py
1020
games/economy.py
File diff suppressed because it is too large
Load diff
|
|
@ -180,7 +180,7 @@ async def handle_uwu_cmd(message: types.Message):
|
||||||
file_url = post["url"]
|
file_url = post["url"]
|
||||||
ext = post["ext"]
|
ext = post["ext"]
|
||||||
caption = post["caption"]
|
caption = post["caption"]
|
||||||
spoiler = post.get("rating") in ("e", "q") # спойлер для explicit и questionable
|
spoiler = post.get("rating") == "e" # спойлер для explicit
|
||||||
|
|
||||||
if ext in ("gif", "webm", "mp4"):
|
if ext in ("gif", "webm", "mp4"):
|
||||||
# Для анимаций: отправляем caption отдельным сообщением,
|
# Для анимаций: отправляем caption отдельным сообщением,
|
||||||
|
|
|
||||||
333
main.py
333
main.py
|
|
@ -1,5 +1,5 @@
|
||||||
# Системные импорты
|
# Системные импорты
|
||||||
import asyncio, json, logging, os, re, random, shutil, threading
|
import asyncio, json, logging, os, re, random, sqlite3, 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
|
||||||
|
|
@ -41,7 +41,6 @@ from games.betting import (
|
||||||
)
|
)
|
||||||
from games.economy import economy
|
from games.economy import economy
|
||||||
from zparser import get_military_data
|
from zparser import get_military_data
|
||||||
from downloader import download_video, COOKIES_PATH
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
@ -140,6 +139,23 @@ 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
|
||||||
|
|
@ -714,29 +730,67 @@ 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:
|
||||||
result = economy.play_penis(
|
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
||||||
user_id, display_name, now_ts,
|
conn.row_factory = sqlite3.Row
|
||||||
config.PENIS_START_LENGTH, config.PENIS_COOLDOWN_SECONDS,
|
row = conn.execute(
|
||||||
config.PENIS_MIN_DELTA, config.PENIS_MAX_DELTA,
|
"SELECT length, last_used_ts, display_name FROM penis_stats WHERE user_id = ?",
|
||||||
)
|
(user_id,),
|
||||||
except Exception:
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
current_length = config.PENIS_START_LENGTH
|
||||||
|
last_used_ts = None
|
||||||
|
else:
|
||||||
|
current_length = float(row["length"])
|
||||||
|
last_used_ts = row["last_used_ts"]
|
||||||
|
stored_name = row["display_name"]
|
||||||
|
if not user_name and isinstance(stored_name, str) and stored_name.strip():
|
||||||
|
display_name = stored_name.strip()
|
||||||
|
|
||||||
|
if last_used_ts is not None:
|
||||||
|
next_ts = int(last_used_ts) + config.PENIS_COOLDOWN_SECONDS
|
||||||
|
if now_ts < next_ts:
|
||||||
|
if row is not None and row["display_name"] != display_name:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE penis_stats SET display_name = ? WHERE user_id = ?",
|
||||||
|
(display_name, user_id),
|
||||||
|
)
|
||||||
|
left_seconds = next_ts - now_ts
|
||||||
|
hours = left_seconds // 3600
|
||||||
|
minutes = (left_seconds % 3600) // 60
|
||||||
|
conn.commit()
|
||||||
|
return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин."
|
||||||
|
|
||||||
|
delta = round(random.uniform(config.PENIS_MIN_DELTA, config.PENIS_MAX_DELTA), 1)
|
||||||
|
sign = random.choice([-1, 1])
|
||||||
|
new_length = round(max(0.1, current_length + sign * delta), 1)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO penis_stats(user_id, display_name, length, last_used_ts) VALUES(?, ?, ?, ?)",
|
||||||
|
(user_id, display_name, new_length, now_ts),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE penis_stats SET display_name = ?, length = ?, last_used_ts = ? WHERE user_id = ?",
|
||||||
|
(display_name, new_length, now_ts, user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Обновляем баланс в экономической системе
|
||||||
|
if sign > 0:
|
||||||
|
economy.update_balance(user_id, delta, 'penis_game', f'Выигрыш в игре +{delta} см')
|
||||||
|
else:
|
||||||
|
economy.update_balance(user_id, delta, 'penis_game', f'Проигрыш в игре {delta} см')
|
||||||
|
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_balance:.1f} см\n"
|
f"Текущая длина: {new_length:.1f} см\n"
|
||||||
"Следующая попытка через 24 часа.",
|
"Следующая попытка через 24 часа.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -749,8 +803,18 @@ 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:
|
||||||
rows = economy.get_penis_top(limit)
|
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
||||||
except Exception:
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT user_id, display_name, length
|
||||||
|
FROM penis_stats
|
||||||
|
ORDER BY length DESC, user_id ASC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.Error:
|
||||||
logger.exception("Failed to build /top_penis")
|
logger.exception("Failed to build /top_penis")
|
||||||
return "Не удалось загрузить топ. Ошибка БД."
|
return "Не удалось загрузить топ. Ошибка БД."
|
||||||
|
|
||||||
|
|
@ -761,10 +825,11 @@ 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"])
|
||||||
balance = float(row["balance"])
|
length = float(row["length"])
|
||||||
name = str(row["display_name"]).strip() or f"user_{user_id}"
|
name = row["display_name"] if isinstance(row["display_name"], str) else f"user_{user_id}"
|
||||||
|
name = name.strip() if name.strip() else f"user_{user_id}"
|
||||||
prefix = medals.get(index, f"{index}.")
|
prefix = medals.get(index, f"{index}.")
|
||||||
lines.append(f"{prefix} {name} — {balance:.1f} см")
|
lines.append(f"{prefix} {name} — {length:.1f} см")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _load_polychaetsi_stats() -> dict:
|
def _load_polychaetsi_stats() -> dict:
|
||||||
|
|
@ -1182,6 +1247,9 @@ 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):
|
||||||
|
|
@ -1263,12 +1331,12 @@ async def handle_deposit_cmd(message: Message):
|
||||||
await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
|
await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
|
||||||
return
|
return
|
||||||
|
|
||||||
success, error_msg = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days)
|
success = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days)
|
||||||
if success:
|
if success:
|
||||||
rate = economy.get_setting('central_bank_rate')
|
rate = economy.get_setting('central_bank_rate')
|
||||||
await message.reply(f"✅ Вклад открыт!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None)
|
await message.reply(f"✅ Вклад открыт!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None)
|
||||||
else:
|
else:
|
||||||
await message.reply(f"❌ Не удалось открыть вклад. {error_msg}", parse_mode=None)
|
await message.reply("❌ Не удалось открыть вклад. Проверь баланс и минимальную сумму.", parse_mode=None)
|
||||||
|
|
||||||
async def handle_loans_cmd(message: Message):
|
async def handle_loans_cmd(message: Message):
|
||||||
"""Показать кредиты"""
|
"""Показать кредиты"""
|
||||||
|
|
@ -1321,7 +1389,7 @@ async def handle_transfer_cmd(message: Message):
|
||||||
|
|
||||||
username = parts[1].lstrip('@')
|
username = parts[1].lstrip('@')
|
||||||
try:
|
try:
|
||||||
amount = round(float(parts[2].replace(",", ".")), 1)
|
amount = float(parts[2].replace(",", "."))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.reply("Сумма должна быть числом.", parse_mode=None)
|
await message.reply("Сумма должна быть числом.", parse_mode=None)
|
||||||
return
|
return
|
||||||
|
|
@ -1330,31 +1398,9 @@ async def handle_transfer_cmd(message: Message):
|
||||||
await message.reply("Сумма должна быть положительной.", parse_mode=None)
|
await message.reply("Сумма должна быть положительной.", parse_mode=None)
|
||||||
return
|
return
|
||||||
|
|
||||||
to_user_id = await asyncio.to_thread(economy.find_user_id_by_username, username)
|
# Здесь нужно найти user_id по username
|
||||||
if not to_user_id:
|
# Для простоты примера, пока покажем сообщение об ошибке
|
||||||
await message.reply(f"Пользователь @{username} не найден. Он должен хотя бы раз сыграть в /penis.", parse_mode=None)
|
await message.reply("🔍 Поиск пользователя...\n(функция поиска пользователей будет добавлена)", parse_mode=None)
|
||||||
return
|
|
||||||
|
|
||||||
if to_user_id == message.from_user.id:
|
|
||||||
await message.reply("Себе переводить нельзя.", parse_mode=None)
|
|
||||||
return
|
|
||||||
|
|
||||||
success = await asyncio.to_thread(
|
|
||||||
economy.transfer_money, message.from_user.id, to_user_id, amount, f"transfer to @{username}"
|
|
||||||
)
|
|
||||||
if success:
|
|
||||||
my_balance = economy.get_user_balance(message.from_user.id)
|
|
||||||
await message.reply(
|
|
||||||
f"✅ Переведено {amount:.1f} см → @{username}\nТвой баланс: {my_balance:.1f} см",
|
|
||||||
parse_mode=None,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
my_balance = economy.get_user_balance(message.from_user.id)
|
|
||||||
limit = round(my_balance * 0.2, 1)
|
|
||||||
await message.reply(
|
|
||||||
f"❌ Не удалось перевести. Баланс: {my_balance:.1f} см, лимит одного перевода: {limit:.1f} см (20%).",
|
|
||||||
parse_mode=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_vanomasa_cmd(message: Message):
|
async def handle_vanomasa_cmd(message: Message):
|
||||||
"""Полное обнуление (только для админов)"""
|
"""Полное обнуление (только для админов)"""
|
||||||
|
|
@ -1400,7 +1446,6 @@ async def handle_central_bank_cmd(message: Message):
|
||||||
/cb_stats - краткая статистика
|
/cb_stats - краткая статистика
|
||||||
/cb_rules - правила работы ЦБ
|
/cb_rules - правила работы ЦБ
|
||||||
"""
|
"""
|
||||||
await message.answer(text, parse_mode=None)
|
|
||||||
|
|
||||||
async def handle_cb_stats_cmd(message: Message):
|
async def handle_cb_stats_cmd(message: Message):
|
||||||
"""Статистика ЦБ для всех пользователей"""
|
"""Статистика ЦБ для всех пользователей"""
|
||||||
|
|
@ -1484,17 +1529,37 @@ async def handle_gadanie_cmd(message: Message):
|
||||||
async def handle_matches_cmd(message: Message):
|
async def handle_matches_cmd(message: Message):
|
||||||
raw_text = message.text or ""
|
raw_text = message.text or ""
|
||||||
parts = raw_text.split(maxsplit=1)
|
parts = raw_text.split(maxsplit=1)
|
||||||
sport_alias = parts[1].strip().lower() if len(parts) > 1 else "dota2"
|
sport_alias = parts[1].strip().lower() if len(parts) > 1 else None
|
||||||
|
|
||||||
|
# Если лига не указана — показать список
|
||||||
|
if not sport_alias:
|
||||||
|
labels = {
|
||||||
|
"epl": "⚽ EPL (Англия)",
|
||||||
|
"ucl": "⚽ Champions League",
|
||||||
|
"laliga": "⚽ La Liga (Испания)",
|
||||||
|
"bundesliga": "⚽ Bundesliga (Германия)",
|
||||||
|
"seriea": "⚽ Serie A (Италия)",
|
||||||
|
"ligue1": "⚽ Ligue 1 (Франция)",
|
||||||
|
"europa": "⚽ Europa League",
|
||||||
|
}
|
||||||
|
lines = ["🏆 Доступные лиги для ставок:\n"]
|
||||||
|
for alias, name in labels.items():
|
||||||
|
lines.append(f" {name} → /matches {alias}")
|
||||||
|
lines.append("\nПример: /matches epl")
|
||||||
|
await message.reply("\n".join(lines), parse_mode=None)
|
||||||
|
return
|
||||||
|
|
||||||
if sport_alias not in config.ODDS_SPORTS:
|
if sport_alias not in config.ODDS_SPORTS:
|
||||||
if message.from_user:
|
if message.from_user:
|
||||||
clear_user_sport_context(message.from_user.id)
|
clear_user_sport_context(message.from_user.id)
|
||||||
await message.reply("Неизвестная лига. Доступно: dota2", parse_mode=None)
|
available = " / ".join(config.ODDS_SPORTS.keys())
|
||||||
|
await message.reply(f"Неизвестная лига. Доступно: {available}", parse_mode=None)
|
||||||
return
|
return
|
||||||
|
|
||||||
if message.from_user:
|
if message.from_user:
|
||||||
remember_user_sport_context(message.from_user.id, sport_alias)
|
if sport_alias in config.ODDS_SPORTS:
|
||||||
|
remember_user_sport_context(message.from_user.id, sport_alias)
|
||||||
|
else:
|
||||||
|
clear_user_sport_context(message.from_user.id)
|
||||||
await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
|
await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
|
||||||
result = await get_formatted_matches(sport_alias)
|
result = await get_formatted_matches(sport_alias)
|
||||||
await message.reply(result, parse_mode=None)
|
await message.reply(result, parse_mode=None)
|
||||||
|
|
@ -1507,34 +1572,41 @@ async def handle_bet_cmd(message: Message):
|
||||||
parts = raw_text.split()
|
parts = raw_text.split()
|
||||||
if len(parts) < 4:
|
if len(parts) < 4:
|
||||||
await message.reply(
|
await message.reply(
|
||||||
"Формат: /bet [номер] [исход] [ставка]\n"
|
"Формат: /bet [спорт] [номер] [исход] [ставка]\n"
|
||||||
"Или: /bet dota2 [номер] [исход] [ставка]\n"
|
"Коротко после /matches cs|dota|football: /bet [номер] [исход] [ставка]\n"
|
||||||
"Исход: 1/2. Можно писать ещё название команды или коэффициент.\n"
|
"Исход: 1/2, для ничьи X, можно ещё название команды или коэффициент.\n"
|
||||||
"Пример: /bet 1 1 0.5",
|
"Пример: /bet cs 1 1 0.5",
|
||||||
parse_mode=None,
|
parse_mode=None,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
sport_alias: str
|
sport_alias: str | None
|
||||||
if parts[1].lower() == "dota2":
|
if parts[1].lower() in config.ODDS_SPORTS:
|
||||||
if len(parts) < 5:
|
if len(parts) < 5:
|
||||||
await message.reply(
|
await message.reply(
|
||||||
"Формат: /bet dota2 [номер] [исход] [ставка]\nПример: /bet dota2 1 1 0.5",
|
"Формат: /bet [спорт] [номер] [исход] [ставка]\nПример: /bet cs 1 1 0.5",
|
||||||
parse_mode=None,
|
parse_mode=None,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
sport_alias = "dota2"
|
sport_alias = parts[1].lower()
|
||||||
index_token = parts[2]
|
index_token = parts[2]
|
||||||
selection = " ".join(parts[3:-1]).strip()
|
selection = " ".join(parts[3:-1]).strip()
|
||||||
amount_token = parts[-1]
|
amount_token = parts[-1]
|
||||||
else:
|
else:
|
||||||
sport_alias = "dota2"
|
sport_alias = get_user_sport_context(message.from_user.id)
|
||||||
|
if not sport_alias:
|
||||||
|
await message.reply(
|
||||||
|
"Сначала открой /matches с нужной лигой (epl, ucl, laliga...).\n"
|
||||||
|
"Либо пиши полный формат: /bet [спорт] [номер] [исход] [ставка]",
|
||||||
|
parse_mode=None,
|
||||||
|
)
|
||||||
|
return
|
||||||
index_token = parts[1]
|
index_token = parts[1]
|
||||||
selection = " ".join(parts[2:-1]).strip()
|
selection = " ".join(parts[2:-1]).strip()
|
||||||
amount_token = parts[-1]
|
amount_token = parts[-1]
|
||||||
|
|
||||||
if not selection:
|
if not selection:
|
||||||
await message.reply("Нужен исход: 1, 2, название команды или коэффициент.", parse_mode=None)
|
await message.reply("Нужен исход: 1, 2, X, название команды или коэффициент.", parse_mode=None)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -1562,7 +1634,7 @@ async def handle_bet_cmd(message: Message):
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
success, result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount)
|
result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount)
|
||||||
await message.reply(result, parse_mode=None)
|
await message.reply(result, parse_mode=None)
|
||||||
|
|
||||||
async def handle_mybets_cmd(message: Message):
|
async def handle_mybets_cmd(message: Message):
|
||||||
|
|
@ -1595,98 +1667,13 @@ async def handle_sports_debug_cmd(message: Message):
|
||||||
result = await debug_sports()
|
result = await debug_sports()
|
||||||
await message.reply(result, parse_mode=None)
|
await message.reply(result, parse_mode=None)
|
||||||
|
|
||||||
_YT_URL_RE = re.compile(
|
|
||||||
r"https?://(?:www\.)?(?:youtube\.com/(?:watch|shorts|live)|youtu\.be)/\S+",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_dow_cmd(message: Message, bot: Bot):
|
|
||||||
parts = (message.text or "").split(maxsplit=1)
|
|
||||||
|
|
||||||
# Если /dow без URL — пробуем взять ссылку из сообщения-источника (reply)
|
|
||||||
url = ""
|
|
||||||
if len(parts) >= 2:
|
|
||||||
url = parts[1].strip()
|
|
||||||
elif message.reply_to_message:
|
|
||||||
src_text = message.reply_to_message.text or message.reply_to_message.caption or ""
|
|
||||||
m = _YT_URL_RE.search(src_text)
|
|
||||||
if m:
|
|
||||||
url = m.group(0)
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
await message.reply("Формат: /dow <ссылка>\nИли ответь на сообщение с YouTube-ссылкой командой /dow")
|
|
||||||
return
|
|
||||||
|
|
||||||
status = await message.reply("⏳ Скачиваю...")
|
|
||||||
file_path, title, error = await download_video(url)
|
|
||||||
|
|
||||||
if error:
|
|
||||||
await status.edit_text(f"❌ Ошибка скачивания:\n{error[:500]}")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
size_mb = file_path.stat().st_size / (1024 * 1024)
|
|
||||||
if size_mb > 50:
|
|
||||||
await status.edit_text(f"❌ Видео весит {size_mb:.1f} МБ — Telegram не позволяет отправить >50 МБ")
|
|
||||||
return
|
|
||||||
|
|
||||||
await status.edit_text("📤 Отправляю...")
|
|
||||||
target = message.reply_to_message or message
|
|
||||||
await target.reply_video(
|
|
||||||
FSInputFile(str(file_path)),
|
|
||||||
caption=title[:1024],
|
|
||||||
supports_streaming=True,
|
|
||||||
)
|
|
||||||
await status.delete()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Failed to send downloaded video")
|
|
||||||
await status.edit_text(f"❌ Не смог отправить: {exc}")
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(str(file_path.parent), ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_setcookies_cmd(message: Message, bot: Bot):
|
|
||||||
"""Загрузить файл кук для YouTube (только в личке, ответом на документ)."""
|
|
||||||
if message.chat.type != "private":
|
|
||||||
await message.reply("Куки можно загрузить только в личных сообщениях с ботом.")
|
|
||||||
return
|
|
||||||
|
|
||||||
doc = None
|
|
||||||
if message.document:
|
|
||||||
doc = message.document
|
|
||||||
elif message.reply_to_message and message.reply_to_message.document:
|
|
||||||
doc = message.reply_to_message.document
|
|
||||||
|
|
||||||
if not doc:
|
|
||||||
await message.reply(
|
|
||||||
"Пришли файл cookies (Netscape/txt) в этот чат с подписью /setcookies "
|
|
||||||
"или ответь на сообщение с файлом командой /setcookies"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
COOKIES_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
file_info = await bot.get_file(doc.file_id)
|
|
||||||
buf = BytesIO()
|
|
||||||
await bot.download_file(file_info.file_path, destination=buf)
|
|
||||||
buf.seek(0)
|
|
||||||
COOKIES_PATH.write_bytes(buf.read())
|
|
||||||
await message.reply(f"✅ Куки сохранены ({doc.file_size or '?'} байт). yt-dlp будет их использовать.")
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Failed to save cookies")
|
|
||||||
await message.reply(f"❌ Не удалось сохранить: {exc}")
|
|
||||||
|
|
||||||
|
|
||||||
async def _settle_loop(bot: Bot):
|
async def _settle_loop(bot: Bot):
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(1800)
|
await asyncio.sleep(1800)
|
||||||
try:
|
try:
|
||||||
notifications = await settle_bets()
|
notifications = await settle_bets()
|
||||||
for user_id, text in notifications:
|
for note in notifications:
|
||||||
try:
|
logger.info(note)
|
||||||
await bot.send_message(user_id, text)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Could not notify user %s about bet result", user_id)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("settle_bets failed")
|
logger.exception("settle_bets failed")
|
||||||
|
|
||||||
|
|
@ -1696,6 +1683,7 @@ 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()
|
||||||
|
|
@ -1727,8 +1715,6 @@ 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_dow_cmd, Command("dow"))
|
|
||||||
dp.message.register(handle_setcookies_cmd, Command("setcookies"))
|
|
||||||
dp.message.register(handle_photo_message, _is_watch_image_message)
|
dp.message.register(handle_photo_message, _is_watch_image_message)
|
||||||
|
|
||||||
# Экономические команды
|
# Экономические команды
|
||||||
|
|
@ -1775,8 +1761,6 @@ 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="dow", description="Скачать видео по ссылке"),
|
|
||||||
BotCommand(command="setcookies", description="Загрузить куки YouTube (личка)"),
|
|
||||||
BotCommand(command="balance", description="💰 баланс и статистика"),
|
BotCommand(command="balance", description="💰 баланс и статистика"),
|
||||||
BotCommand(command="deposit", description="💎 открыть вклад"),
|
BotCommand(command="deposit", description="💎 открыть вклад"),
|
||||||
BotCommand(command="loan", description="💵 взять кредит"),
|
BotCommand(command="loan", description="💵 взять кредит"),
|
||||||
|
|
@ -1798,28 +1782,25 @@ def main():
|
||||||
# Запуск фоновых задач экономики
|
# Запуск фоновых задач экономики
|
||||||
asyncio.create_task(economy_background_tasks())
|
asyncio.create_task(economy_background_tasks())
|
||||||
|
|
||||||
dp.startup.register(on_startup)
|
|
||||||
|
|
||||||
async def start_settle(bot: Bot):
|
|
||||||
asyncio.create_task(_settle_loop(bot))
|
|
||||||
dp.startup.register(start_settle)
|
|
||||||
|
|
||||||
logger.info("Бот запущен...")
|
|
||||||
asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()))
|
|
||||||
|
|
||||||
|
|
||||||
async def economy_background_tasks():
|
async def economy_background_tasks():
|
||||||
"""Фоновые задачи для экономики"""
|
"""Фоновые задачи для экономики"""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
# Проверяем текущее время по МСК
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
moscow_time = datetime.now(ZoneInfo("Europe/Moscow"))
|
moscow_time = datetime.now(ZoneInfo("Europe/Moscow"))
|
||||||
current_hour = moscow_time.hour
|
current_hour = moscow_time.hour
|
||||||
current_minute = moscow_time.minute
|
current_minute = moscow_time.minute
|
||||||
|
|
||||||
|
# Ежедневный сбор налогов (каждый час проверяем)
|
||||||
await asyncio.to_thread(economy.collect_daily_taxes)
|
await asyncio.to_thread(economy.collect_daily_taxes)
|
||||||
await asyncio.to_thread(economy.regulate_inflation)
|
|
||||||
await asyncio.to_thread(economy.process_matured_deposits)
|
|
||||||
|
|
||||||
|
# Регулирование инфляции (раз в день)
|
||||||
|
await asyncio.to_thread(economy.regulate_inflation)
|
||||||
|
|
||||||
|
# Выплаты за активность в 00:00 по МСК
|
||||||
if current_hour == 0 and current_minute == 0:
|
if current_hour == 0 and current_minute == 0:
|
||||||
users_paid = await asyncio.to_thread(economy.pay_daily_activity_rewards)
|
users_paid = await asyncio.to_thread(economy.pay_daily_activity_rewards)
|
||||||
if users_paid > 0:
|
if users_paid > 0:
|
||||||
|
|
@ -1829,7 +1810,17 @@ async def economy_background_tasks():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Economy background task failed: {e}")
|
logger.exception(f"Economy background task failed: {e}")
|
||||||
|
|
||||||
|
# Ждём 1 минуту до следующей проверки (для точности в 00:00)
|
||||||
await asyncio.sleep(60)
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
dp.startup.register(on_startup)
|
||||||
|
|
||||||
|
async def start_settle(bot: Bot):
|
||||||
|
asyncio.create_task(_settle_loop(bot))
|
||||||
|
dp.startup.register(start_settle)
|
||||||
|
|
||||||
|
logger.info("Бот запущен...")
|
||||||
|
asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,4 @@ aiohttp>=3.9.5
|
||||||
tzdata>=2024.1
|
tzdata>=2024.1
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
lxml
|
lxml
|
||||||
psycopg2-binary>=2.9.9
|
pysqlite3-binary>=0.5.3
|
||||||
yt-dlp>=2024.1.0
|
|
||||||
|
|
|
||||||
|
|
@ -1,369 +0,0 @@
|
||||||
#!/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()
|
|
||||||
|
|
@ -10,7 +10,6 @@ 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
|
from urllib.parse import urlparse
|
||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
@ -111,23 +110,10 @@ class SchedulePair(BaseModel):
|
||||||
|
|
||||||
# ──────────────────── Lifespan ────────────────────
|
# ──────────────────── Lifespan ────────────────────
|
||||||
|
|
||||||
_http_session: aiohttp.ClientSession | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_http_session() -> aiohttp.ClientSession:
|
|
||||||
return _http_session
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
global _http_session
|
|
||||||
_http_session = aiohttp.ClientSession(
|
|
||||||
timeout=aiohttp.ClientTimeout(total=120),
|
|
||||||
connector=aiohttp.TCPConnector(limit=64, ttl_dns_cache=300),
|
|
||||||
)
|
|
||||||
logger.info("Mini App API started")
|
logger.info("Mini App API started")
|
||||||
yield
|
yield
|
||||||
await _http_session.close()
|
|
||||||
logger.info("Mini App API stopped")
|
logger.info("Mini App API stopped")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -158,16 +144,15 @@ 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:
|
||||||
from db import get_conn
|
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
||||||
with get_conn() as conn:
|
row = conn.execute(
|
||||||
cur = conn.cursor()
|
"SELECT COUNT(*) FROM bets WHERE user_id = ? AND status = 'pending'",
|
||||||
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
|
||||||
|
|
@ -187,7 +172,13 @@ async def get_profile(user: dict = Depends(get_current_user)) -> ProfileResponse
|
||||||
async def get_leagues() -> list[LeagueInfo]:
|
async def get_leagues() -> list[LeagueInfo]:
|
||||||
"""Список доступных лиг для ставок."""
|
"""Список доступных лиг для ставок."""
|
||||||
labels = {
|
labels = {
|
||||||
"dota2": "🎮 Dota 2",
|
"epl": "⚽ EPL (Англия)",
|
||||||
|
"ucl": "⚽ Champions League",
|
||||||
|
"laliga": "⚽ La Liga (Испания)",
|
||||||
|
"bundesliga": "⚽ Bundesliga (Германия)",
|
||||||
|
"seriea": "⚽ Serie A (Италия)",
|
||||||
|
"ligue1": "⚽ Ligue 1 (Франция)",
|
||||||
|
"europa": "⚽ Europa League",
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
LeagueInfo(alias=alias, name=labels.get(alias, alias), sport_key=key)
|
LeagueInfo(alias=alias, name=labels.get(alias, alias), sport_key=key)
|
||||||
|
|
@ -253,9 +244,7 @@ async def create_bet(
|
||||||
raise HTTPException(status_code=400, detail="Неверный исход ставки")
|
raise HTTPException(status_code=400, detail="Неверный исход ставки")
|
||||||
|
|
||||||
# 3. Разместить ставку (синхронная функция)
|
# 3. Разместить ставку (синхронная функция)
|
||||||
success, result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount)
|
result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount)
|
||||||
if not success:
|
|
||||||
raise HTTPException(status_code=400, detail=result)
|
|
||||||
return {"message": result}
|
return {"message": result}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -296,7 +285,7 @@ async def play_casino_api(
|
||||||
raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см")
|
raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см")
|
||||||
if req.bet <= 0:
|
if req.bet <= 0:
|
||||||
raise HTTPException(status_code=400, detail="Ставка > 0")
|
raise HTTPException(status_code=400, detail="Ставка > 0")
|
||||||
if round(req.bet, 2) > round(current, 2):
|
if req.bet > current:
|
||||||
raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см")
|
raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см")
|
||||||
|
|
||||||
reels, match_count = await asyncio.to_thread(spin_slots)
|
reels, match_count = await asyncio.to_thread(spin_slots)
|
||||||
|
|
@ -494,62 +483,23 @@ async def proxy_shorties_media(url: str, request: Request):
|
||||||
if range_header:
|
if range_header:
|
||||||
headers["Range"] = range_header
|
headers["Range"] = range_header
|
||||||
|
|
||||||
session = get_http_session()
|
timeout = aiohttp.ClientTimeout(total=120)
|
||||||
|
session = aiohttp.ClientSession(timeout=timeout)
|
||||||
try:
|
try:
|
||||||
resp = await session.get(url, headers=headers, allow_redirects=True)
|
resp = await session.get(url, headers=headers, allow_redirects=True)
|
||||||
except aiohttp.ClientError:
|
except aiohttp.ClientError:
|
||||||
|
await session.close()
|
||||||
raise HTTPException(status_code=502, detail="Failed to fetch media")
|
raise HTTPException(status_code=502, detail="Failed to fetch media")
|
||||||
|
|
||||||
if resp.status not in {200, 206}:
|
if resp.status not in {200, 206}:
|
||||||
await resp.release()
|
await resp.release()
|
||||||
|
await session.close()
|
||||||
raise HTTPException(status_code=resp.status, detail="Upstream error")
|
raise HTTPException(status_code=resp.status, detail="Upstream error")
|
||||||
|
|
||||||
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
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()
|
|
||||||
|
|
||||||
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 = {
|
passthrough_headers = {
|
||||||
"Cache-Control": "public, max-age=600",
|
"Cache-Control": "public, max-age=600",
|
||||||
"Accept-Ranges": resp.headers.get("Accept-Ranges", "bytes"),
|
"Accept-Ranges": resp.headers.get("Accept-Ranges", "bytes"),
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
}
|
}
|
||||||
if resp.headers.get("Content-Length"):
|
if resp.headers.get("Content-Length"):
|
||||||
passthrough_headers["Content-Length"] = resp.headers["Content-Length"]
|
passthrough_headers["Content-Length"] = resp.headers["Content-Length"]
|
||||||
|
|
@ -562,6 +512,7 @@ async def proxy_shorties_media(url: str, request: Request):
|
||||||
yield chunk
|
yield chunk
|
||||||
finally:
|
finally:
|
||||||
await resp.release()
|
await resp.release()
|
||||||
|
await session.close()
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
_stream(),
|
_stream(),
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,7 @@ 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) => {
|
document.querySelectorAll('.furtok-card video').forEach((video) => {
|
||||||
_videoObserver.unobserve(video);
|
if (video && video._hlsInstance) {
|
||||||
if (video._hlsInstance) {
|
|
||||||
try { video._hlsInstance.destroy(); } catch {}
|
try { video._hlsInstance.destroy(); } catch {}
|
||||||
video._hlsInstance = null;
|
video._hlsInstance = null;
|
||||||
}
|
}
|
||||||
|
|
@ -84,10 +83,6 @@ function escapeHtml(value) {
|
||||||
|
|
||||||
function initShortiesVideoPlayback(video) {
|
function initShortiesVideoPlayback(video) {
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
// Если уже есть mp4-src — HLS не нужен, браузер справится сам
|
|
||||||
if (video.src && !video.src.endsWith('#')) return;
|
|
||||||
|
|
||||||
const hlsSrc = video.dataset.hlsSrc || '';
|
const hlsSrc = video.dataset.hlsSrc || '';
|
||||||
if (!hlsSrc) return;
|
if (!hlsSrc) return;
|
||||||
|
|
||||||
|
|
@ -99,10 +94,9 @@ function initShortiesVideoPlayback(video) {
|
||||||
|
|
||||||
if (window.Hls && window.Hls.isSupported()) {
|
if (window.Hls && window.Hls.isSupported()) {
|
||||||
const hls = new window.Hls({
|
const hls = new window.Hls({
|
||||||
maxBufferLength: 15,
|
maxBufferLength: 30,
|
||||||
backBufferLength: 5,
|
backBufferLength: 30,
|
||||||
enableWorker: true,
|
enableWorker: true,
|
||||||
lowLatencyMode: false,
|
|
||||||
});
|
});
|
||||||
hls.loadSource(hlsSrc);
|
hls.loadSource(hlsSrc);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
|
|
@ -110,23 +104,6 @@ function initShortiesVideoPlayback(video) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IntersectionObserver: играем только видимое видео, паузим скрытые
|
|
||||||
const _videoObserver = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
const video = entry.target;
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
video.play().catch(() => {});
|
|
||||||
} else {
|
|
||||||
video.pause();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, { threshold: 0.6 });
|
|
||||||
|
|
||||||
function observeVideo(video) {
|
|
||||||
video.removeAttribute('autoplay');
|
|
||||||
_videoObserver.observe(video);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getShortiesShareUrl(post) {
|
function getShortiesShareUrl(post) {
|
||||||
const source = String(post?.source || '').trim();
|
const source = String(post?.source || '').trim();
|
||||||
if (source) return source;
|
if (source) return source;
|
||||||
|
|
@ -318,17 +295,13 @@ async function showBetHistory() {
|
||||||
|
|
||||||
async function renderBetting(data) {
|
async function renderBetting(data) {
|
||||||
if (data?.league) {
|
if (data?.league) {
|
||||||
return renderMatches(data.league, data.leagueName, data.isOnlyLeague);
|
return renderMatches(data.league, data.leagueName);
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = `<div class="section-header">⚽ Ставки на матчи</div>${skeleton(2)}`;
|
container.innerHTML = `<div class="section-header">⚽ Ставки на матчи</div>${skeleton(2)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const leagues = await API.getLeagues();
|
const leagues = await API.getLeagues();
|
||||||
if (leagues.length === 1) {
|
|
||||||
return renderMatches(leagues[0].alias, leagues[0].name, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="section-header">⚽ Выбери лигу</div>
|
<div class="section-header">⚽ Выбери лигу</div>
|
||||||
<div class="league-list" id="league-list"></div>
|
<div class="league-list" id="league-list"></div>
|
||||||
|
|
@ -344,7 +317,7 @@ async function renderBetting(data) {
|
||||||
`);
|
`);
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
haptic();
|
haptic();
|
||||||
navigate('betting', { league: l.alias, leagueName: l.name, isOnlyLeague: false });
|
navigate('betting', { league: l.alias, leagueName: l.name });
|
||||||
});
|
});
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
});
|
});
|
||||||
|
|
@ -353,22 +326,15 @@ async function renderBetting(data) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderMatches(league, leagueName, isOnlyLeague = false) {
|
async function renderMatches(league, leagueName) {
|
||||||
const headerText = isOnlyLeague ? '🎮 Dota 2 Матчи' : leagueName;
|
|
||||||
const backTarget = isOnlyLeague ? 'home' : 'betting';
|
|
||||||
const instructionsHtml = isOnlyLeague
|
|
||||||
? `<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">Ставки на актуальные матчи Dota 2. Выберите исход и укажите сумму ставки в сантиметрах.</div>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
|
||||||
<button class="btn btn-secondary btn-sm" id="back-btn" style="width:auto;padding:8px 12px">← Назад</button>
|
<button class="btn btn-secondary btn-sm" id="back-btn" style="width:auto;padding:8px 12px">← Назад</button>
|
||||||
<div class="section-header" style="margin:0">${headerText}</div>
|
<div class="section-header" style="margin:0">${leagueName}</div>
|
||||||
</div>
|
</div>
|
||||||
${instructionsHtml}
|
|
||||||
${skeleton(3)}
|
${skeleton(3)}
|
||||||
`;
|
`;
|
||||||
document.getElementById('back-btn').onclick = () => navigate(backTarget);
|
document.getElementById('back-btn').onclick = () => navigate('betting');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const matches = await API.getMatches(league);
|
const matches = await API.getMatches(league);
|
||||||
|
|
@ -395,12 +361,10 @@ async function renderMatches(league, leagueName, isOnlyLeague = false) {
|
||||||
<span class="odds-label">П1</span>
|
<span class="odds-label">П1</span>
|
||||||
<span class="odds-value">${m.odds_home?.toFixed(2) || '—'}</span>
|
<span class="odds-value">${m.odds_home?.toFixed(2) || '—'}</span>
|
||||||
</button>
|
</button>
|
||||||
${m.odds_draw ? `
|
|
||||||
<button class="odds-btn" data-outcome="X" data-odds="${m.odds_draw || 0}">
|
<button class="odds-btn" data-outcome="X" data-odds="${m.odds_draw || 0}">
|
||||||
<span class="odds-label">X</span>
|
<span class="odds-label">X</span>
|
||||||
<span class="odds-value">${m.odds_draw?.toFixed(2) || '—'}</span>
|
<span class="odds-value">${m.odds_draw?.toFixed(2) || '—'}</span>
|
||||||
</button>
|
</button>
|
||||||
` : ''}
|
|
||||||
<button class="odds-btn" data-outcome="2" data-odds="${m.odds_away || 0}">
|
<button class="odds-btn" data-outcome="2" data-odds="${m.odds_away || 0}">
|
||||||
<span class="odds-label">П2</span>
|
<span class="odds-label">П2</span>
|
||||||
<span class="odds-value">${m.odds_away?.toFixed(2) || '—'}</span>
|
<span class="odds-value">${m.odds_away?.toFixed(2) || '—'}</span>
|
||||||
|
|
@ -916,12 +880,10 @@ async function loadFurtokPage(feedEl) {
|
||||||
} else {
|
} else {
|
||||||
const poster = post.sample ? ` poster="${post.sample}"` : '';
|
const poster = post.sample ? ` poster="${post.sample}"` : '';
|
||||||
const directMp4 = post.mp4_url || post.url || '';
|
const directMp4 = post.mp4_url || post.url || '';
|
||||||
const proxyMp4 = isShorties ? API.proxyShortiesMediaUrl(directMp4) : post.url;
|
const hasHls = isShorties && Boolean(post.hls_url);
|
||||||
const proxyHls = isShorties && post.hls_url ? API.proxyShortiesMediaUrl(post.hls_url) : '';
|
const videoSrc = hasHls ? '' : (isShorties ? API.proxyShortiesMediaUrl(directMp4) : post.url);
|
||||||
const useHls = isShorties && !directMp4 && Boolean(post.hls_url);
|
const hlsSrc = isShorties ? escapeHtml(post.hls_url || '') : '';
|
||||||
const videoSrc = useHls ? '' : proxyMp4;
|
mediaHtml = `<video src="${videoSrc}" data-direct-src="${escapeHtml(directMp4)}" data-hls-src="${hlsSrc}"${poster} loop autoplay playsinline preload="auto" muted></video>`;
|
||||||
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 playsinline preload="metadata" muted></video>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = post.title ? escapeHtml(post.title) : '';
|
const title = post.title ? escapeHtml(post.title) : '';
|
||||||
|
|
@ -956,8 +918,8 @@ async function loadFurtokPage(feedEl) {
|
||||||
initShortiesVideoPlayback(video);
|
initShortiesVideoPlayback(video);
|
||||||
}
|
}
|
||||||
video.addEventListener('error', () => {
|
video.addEventListener('error', () => {
|
||||||
if (isShorties && video.dataset.proxySrc && video.src !== video.dataset.proxySrc) {
|
if (isShorties && video.dataset.directSrc && video.src !== video.dataset.directSrc) {
|
||||||
video.src = video.dataset.proxySrc;
|
video.src = video.dataset.directSrc;
|
||||||
video.load();
|
video.load();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -966,7 +928,6 @@ async function loadFurtokPage(feedEl) {
|
||||||
if (video.paused) video.play();
|
if (video.paused) video.play();
|
||||||
else video.pause();
|
else video.pause();
|
||||||
});
|
});
|
||||||
observeVideo(video);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const shareBtn = card.querySelector('.furtok-share-btn');
|
const shareBtn = card.querySelector('.furtok-share-btn');
|
||||||
|
|
@ -990,6 +951,14 @@ async function loadFurtokPage(feedEl) {
|
||||||
furtokCards.push(card);
|
furtokCards.push(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Автоплей первого видео при первой загрузке
|
||||||
|
if (furtokCurrentIndex === 0 && furtokCards.length > 0) {
|
||||||
|
const firstVideo = furtokCards[0].querySelector('video');
|
||||||
|
if (firstVideo) {
|
||||||
|
firstVideo.muted = true;
|
||||||
|
firstVideo.play().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
haptic('success');
|
haptic('success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,7 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
resolver 127.0.0.11 valid=10s;
|
proxy_pass http://webapp-api:8080;
|
||||||
set $api http://webapp-api:8080;
|
|
||||||
proxy_pass $api;
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|
@ -42,9 +40,6 @@ 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 / {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue