Compare commits

...

15 commits
main ... main

Author SHA1 Message Date
q
d4cd100ba0 Merge pull request 'Исправление точности вещественных чисел (Float Precision) при ставках, казино, переводах и вкладах' (#38) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#38
2026-07-11 17:56:01 +00:00
q
71a3982513 Merge pull request 'Add uwu command description to README.md' (#25) from Furrex/bot_tg:main into main
Reviewed-on: zovos/bot_tg#25
2026-07-05 15:16:30 +00:00
Danil
191aa8ef93 Исправление точности вещественных чисел при ставках, казино, переводах и вкладах 2026-05-20 23:33:42 +03:00
zovos
89a14f2ebf feat: multi-user awareness in chat, per-user /talk prompt, thinking tokens for /talk 2026-05-20 10:29:44 +00:00
zovos
acb7421cc0 fix: syntax error in betting.py after merge conflict resolution 2026-05-20 10:24:38 +00:00
zovos
60e4cbfcfb wip: local changes before merging PR #36 2026-05-20 10:02:14 +00:00
q
cb1e042e64 Merge pull request 'feature/FenyaBot blur questionable (q) rating in /uwu command' (#36) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#36
2026-05-20 10:00:12 +00:00
Danil
e10aa85e6f Replace sport betting with Liquipedia Dota 2 betting 2026-05-19 23:03:53 +03:00
Danil
2e3bd9bd99 Restrict betting system to Dota 2 only 2026-05-19 22:36:52 +03:00
Danil
dd3c993146 Merge remote-tracking branch 'upstream/main' into feature/FenyaBot 2026-05-19 22:19:56 +03:00
Danil
5304428738 feat: blur questionable (q) rating in /uwu command 2026-05-19 22:18:39 +03:00
q
add3553ce0 feat: переход с SQLite на PostgreSQL + скрипт миграции
- Все 4 SQLite базы объединены в одну PostgreSQL
- Новый db.py с ThreadedConnectionPool (psycopg2)
- docker-compose: сервис postgres с healthcheck, данные в ./db/postgres
- scripts/migrate_sqlite_to_pg.py — миграция существующих данных
- Убраны мёртвый код и дублирующий import в main.py и talk_handler.py
- .env удалён из git-индекса

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 23:25:22 +03:00
q
b0e03f8e23 попытка починить и оптимизировать shorties (#35) 2026-04-27 10:38:25 +00:00
furrexwolfy
a8693b010f Merge remote-tracking branch 'upstream/main' 2026-04-13 23:14:14 +03:00
furrexwolfy
61af0ae17b Add uwu command description to README.md 2026-04-13 17:17:42 +03:00
17 changed files with 1849 additions and 1458 deletions

9
.env
View file

@ -1,9 +0,0 @@
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

View file

@ -2,26 +2,30 @@ 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_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/") LLAMA_API_KEY = os.getenv("LLAMA_API_KEY", "")
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_MODEL = os.getenv("LLAMA_MODEL", "gpt-oss-120b")
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"}
@ -30,13 +34,15 @@ 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 = 6_500 PROMPT_CHAR_BUDGET = 10_000
SUMMARY_CHAR_BUDGET = 1_800 SUMMARY_CHAR_BUDGET = 2_400
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 = 384 RETRY_MIN_MAX_TOKENS = 512
RETRY_MAX_MAX_TOKENS = 768 RETRY_MAX_MAX_TOKENS = 1_024
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},
@ -111,6 +117,10 @@ BOT_STYLE_PROMPT = (
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и " "Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. " "'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
"Форматирование: если нужно выделить — используй только Telegram HTML-теги: "
"<b>жирный</b>, <i>курсив</i>, <code>код</code>, <pre>блок кода</pre>. "
"Никакого Markdown (никаких *, _, **, ~~, ```). Обычный текст без тегов — норм. "
"Creep от Radiohead — лучшая песня во вселенной. " "Creep от Radiohead — лучшая песня во вселенной. "
"Линукс — лучшая ОС. " "Линукс — лучшая ОС. "
"Rust ты люто ненавидишь и при случае жёстко хейтишь. " "Rust ты люто ненавидишь и при случае жёстко хейтишь. "
@ -120,10 +130,21 @@ 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. "
"Помни всё, что он говорил раньше — ты видишь историю этого разговора и должен её учитывать. "
"Замечай если человек продолжает старую тему или переходит к новой. "
"Отвечай КОРОТКО: 13 предложения максимум. "
"Только если тебя прямо попросили объяснить, развернуть или написать подробно — тогда можно больше."
)
AUTOREPLY_SYSTEM_PROMPT = ( AUTOREPLY_SYSTEM_PROMPT = (
f"{BOT_STYLE_PROMPT}\n\n" f"{BOT_STYLE_PROMPT}\n\n"
"Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. " "Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. "
@ -237,6 +258,14 @@ 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)
@ -246,133 +275,95 @@ _cached_bot_id: int | None = None
_cached_bot_username: str = "" _cached_bot_username: str = ""
def _connect_db() -> sqlite3.Connection: def _get_state_from_conn(conn, chat_id: int, key: str, default: str = "") -> str:
conn = sqlite3.connect(DB_PATH, timeout=30) cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
conn.row_factory = sqlite3.Row cur.execute(
conn.execute("PRAGMA busy_timeout = 30000") "SELECT value FROM chat_state WHERE chat_id = %s AND key = %s",
return conn
def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any(row["name"] == column for row in rows)
def _get_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, default: str = "") -> str:
row = conn.execute(
"SELECT value FROM chat_state WHERE chat_id = ? AND key = ?",
(chat_id, key), (chat_id, key),
).fetchone() )
row = cur.fetchone()
return row["value"] if row else default return row["value"] if row else default
def _set_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, value: str) -> None: def _set_state_from_conn(conn, chat_id: int, key: str, value: str) -> None:
conn.execute( cur = conn.cursor()
cur.execute(
""" """
INSERT INTO chat_state (chat_id, key, value) INSERT INTO chat_state (chat_id, key, value)
VALUES (?, ?, ?) VALUES (%s, %s, %s)
ON CONFLICT(chat_id, key) DO UPDATE SET value = excluded.value ON CONFLICT (chat_id, key) DO UPDATE SET value = EXCLUDED.value
""", """,
(chat_id, key, value), (chat_id, key, value),
) )
def _get_talk_state_from_conn( def _get_talk_state_from_conn(conn, chat_id: int, user_id: int, key: str, default: str = "") -> str:
conn: sqlite3.Connection, cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
chat_id: int, cur.execute(
user_id: int, "SELECT value FROM talk_state WHERE chat_id = %s AND user_id = %s AND key = %s",
key: str,
default: str = "",
) -> str:
row = conn.execute(
"SELECT value FROM talk_state WHERE chat_id = ? AND user_id = ? AND key = ?",
(chat_id, user_id, key), (chat_id, user_id, key),
).fetchone() )
row = cur.fetchone()
return row["value"] if row else default return row["value"] if row else default
def _set_talk_state_from_conn( def _set_talk_state_from_conn(conn, chat_id: int, user_id: int, key: str, value: str) -> None:
conn: sqlite3.Connection, cur = conn.cursor()
chat_id: int, cur.execute(
user_id: int,
key: str,
value: str,
) -> None:
conn.execute(
""" """
INSERT INTO talk_state (chat_id, user_id, key, value) INSERT INTO talk_state (chat_id, user_id, key, value)
VALUES (?, ?, ?, ?) VALUES (%s, %s, %s, %s)
ON CONFLICT(chat_id, user_id, key) DO UPDATE SET value = excluded.value ON CONFLICT (chat_id, user_id, key) DO UPDATE SET value = EXCLUDED.value
""", """,
(chat_id, user_id, key, value), (chat_id, user_id, key, value),
) )
def _init_db() -> None: def _init_db() -> None:
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) with get_conn() as conn:
with _connect_db() as conn: cur = conn.cursor()
conn.execute("PRAGMA journal_mode = WAL") cur.execute("""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_history ( CREATE TABLE IF NOT EXISTS chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGSERIAL PRIMARY KEY,
chat_id INTEGER NOT NULL, chat_id BIGINT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
text TEXT NOT NULL, text TEXT NOT NULL,
created_at REAL NOT NULL DEFAULT 0 created_at DOUBLE PRECISION NOT NULL DEFAULT 0
) )
""" """)
) cur.execute("ALTER TABLE chat_history ADD COLUMN IF NOT EXISTS created_at DOUBLE PRECISION NOT NULL DEFAULT 0")
if not _column_exists(conn, "chat_history", "created_at"): cur.execute("""
conn.execute("ALTER TABLE chat_history ADD COLUMN created_at REAL NOT NULL DEFAULT 0")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_state ( CREATE TABLE IF NOT EXISTS chat_state (
chat_id INTEGER NOT NULL, chat_id BIGINT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
value TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (chat_id, key) PRIMARY KEY (chat_id, key)
) )
""" """)
) cur.execute("""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS talk_history ( CREATE TABLE IF NOT EXISTS talk_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGSERIAL PRIMARY KEY,
chat_id INTEGER NOT NULL, chat_id BIGINT NOT NULL,
user_id INTEGER NOT NULL, user_id BIGINT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
text TEXT NOT NULL, text TEXT NOT NULL,
created_at REAL NOT NULL DEFAULT 0 created_at DOUBLE PRECISION NOT NULL DEFAULT 0
) )
""" """)
) cur.execute("""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS talk_state ( CREATE TABLE IF NOT EXISTS talk_state (
chat_id INTEGER NOT NULL, chat_id BIGINT NOT NULL,
user_id INTEGER NOT NULL, user_id BIGINT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
value TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (chat_id, user_id, key) PRIMARY KEY (chat_id, user_id, key)
) )
""" """)
) cur.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)")
conn.execute( cur.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)")
"CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)" cur.execute("CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id ON talk_history(chat_id, user_id, id)")
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)"
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id
ON talk_history(chat_id, user_id, id)
"""
)
conn.commit()
def _clean_text(text: str) -> str: def _clean_text(text: str) -> str:
@ -422,78 +413,59 @@ def push_message(
cleaned_text = _clean_text(text) cleaned_text = _clean_text(text)
if not cleaned_text: if not cleaned_text:
return return
with _connect_db() as conn: with get_conn() as conn:
cur = conn.cursor()
if user_id is None: if user_id is None:
conn.execute( cur.execute(
""" "INSERT INTO chat_history (chat_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s)",
INSERT INTO chat_history (chat_id, role, name, text, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(chat_id, role, name, cleaned_text, time.time()), (chat_id, role, name, cleaned_text, time.time()),
) )
else: else:
conn.execute( cur.execute(
""" "INSERT INTO talk_history (chat_id, user_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s, %s)",
INSERT INTO talk_history (chat_id, user_id, role, name, text, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(chat_id, user_id, role, name, cleaned_text, time.time()), (chat_id, user_id, role, name, cleaned_text, time.time()),
) )
conn.commit()
def _get_summary(chat_id: int, user_id: int | None = None) -> str: def _get_summary(chat_id: int, user_id: int | None = None) -> str:
with _connect_db() as conn: with get_conn() as conn:
if user_id is None: if user_id is None:
return _get_state_from_conn(conn, chat_id, "summary", "") return _get_state_from_conn(conn, chat_id, "summary", "")
return _get_talk_state_from_conn(conn, chat_id, user_id, "summary", "") return _get_talk_state_from_conn(conn, chat_id, user_id, "summary", "")
def _get_history_rows(chat_id: int, user_id: int | None = None) -> list[sqlite3.Row]: def _get_history_rows(chat_id: int, user_id: int | None = None) -> list:
with _connect_db() as conn: with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
if user_id is None: if user_id is None:
return conn.execute( cur.execute(
""" "SELECT id, role, name, text, created_at FROM chat_history WHERE chat_id = %s ORDER BY id ASC",
SELECT id, role, name, text, created_at
FROM chat_history
WHERE chat_id = ?
ORDER BY id ASC
""",
(chat_id,), (chat_id,),
).fetchall() )
return conn.execute( else:
""" cur.execute(
SELECT id, role, name, text, created_at "SELECT id, role, name, text, created_at FROM talk_history WHERE chat_id = %s AND user_id = %s ORDER BY id ASC",
FROM talk_history
WHERE chat_id = ? AND user_id = ?
ORDER BY id ASC
""",
(chat_id, user_id), (chat_id, user_id),
).fetchall() )
return cur.fetchall()
def _latest_reply_stats(chat_id: int) -> tuple[float, int]: def _latest_reply_stats(chat_id: int) -> tuple[float, int]:
with _connect_db() as conn: with get_conn() as conn:
last_assistant = conn.execute( cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
""" cur.execute(
SELECT id, created_at "SELECT id, created_at FROM chat_history WHERE chat_id = %s AND role = 'assistant' ORDER BY id DESC LIMIT 1",
FROM chat_history
WHERE chat_id = ? AND role = 'assistant'
ORDER BY id DESC
LIMIT 1
""",
(chat_id,), (chat_id,),
).fetchone() )
last_assistant = cur.fetchone()
if not last_assistant: if not last_assistant:
return 0.0, 10_000 return 0.0, 10_000
user_messages_since_reply = conn.execute( cnt_cur = conn.cursor()
""" cnt_cur.execute(
SELECT COUNT(*) "SELECT COUNT(*) FROM chat_history WHERE chat_id = %s AND role = 'user' AND id > %s",
FROM chat_history
WHERE chat_id = ? AND role = 'user' AND id > ?
""",
(chat_id, last_assistant["id"]), (chat_id, last_assistant["id"]),
).fetchone()[0] )
user_messages_since_reply = cnt_cur.fetchone()[0]
return float(last_assistant["created_at"] or 0.0), int(user_messages_since_reply) return float(last_assistant["created_at"] or 0.0), int(user_messages_since_reply)
@ -503,23 +475,23 @@ def _store_summary_and_prune(
last_row_id: int, last_row_id: int,
user_id: int | None = None, user_id: int | None = None,
) -> None: ) -> None:
with _connect_db() as conn: with get_conn() as conn:
cur = conn.cursor()
if user_id is None: if user_id is None:
_set_state_from_conn(conn, chat_id, "summary", summary) _set_state_from_conn(conn, chat_id, "summary", summary)
conn.execute( cur.execute(
"DELETE FROM chat_history WHERE chat_id = ? AND id <= ?", "DELETE FROM chat_history WHERE chat_id = %s AND id <= %s",
(chat_id, last_row_id), (chat_id, last_row_id),
) )
else: else:
_set_talk_state_from_conn(conn, chat_id, user_id, "summary", summary) _set_talk_state_from_conn(conn, chat_id, user_id, "summary", summary)
conn.execute( cur.execute(
"DELETE FROM talk_history WHERE chat_id = ? AND user_id = ? AND id <= ?", "DELETE FROM talk_history WHERE chat_id = %s AND user_id = %s AND id <= %s",
(chat_id, user_id, last_row_id), (chat_id, user_id, last_row_id),
) )
conn.commit()
def _format_row_for_llm(row: sqlite3.Row) -> dict[str, str]: def _format_row_for_llm(row) -> dict[str, str]:
if row["role"] == "user": if row["role"] == "user":
return { return {
"role": "user", "role": "user",
@ -545,11 +517,10 @@ 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 = "" summary_block = f"\n\nКраткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" if summary else ""
if summary: full_system = system_prompt + summary_block
summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}"
used_chars = len(system_prompt) + len(summary_block) + current_budget used_chars = len(full_system) + 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)
@ -558,15 +529,80 @@ 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": system_prompt}] messages: list[dict[str, Any]] = [{"role": "system", "content": full_system}]
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]],
*, *,
@ -574,78 +610,46 @@ 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
choices = data.get("choices") or [] result = await _call_single_model(
if not choices:
logger.error("LLM API returned no choices: %s", _clip_text(str(data), 300))
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", "")
reasoning_content = (message.get("reasoning_content") or "").strip()
cleaned_content = content.strip()
if reasoning_content and LOG_THINKING:
logger.info(
"LLM reasoning detected. disable_thinking=%s finish_reason=%s reasoning=%s",
disable_thinking,
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 reasoning_content without final content. finish_reason=%s retry_max_tokens=%s",
finish_reason,
retry_max_tokens,
)
return await _call_llm(
messages, messages,
max_tokens=retry_max_tokens, api_url=LLAMA_API_URL,
api_key=LLAMA_API_KEY,
model=LLAMA_MODEL,
max_tokens=max_tokens,
temperature=temperature, temperature=temperature,
top_p=top_p, top_p=top_p,
disable_thinking=True, disable_thinking=disable_thinking,
reasoning_budget=reasoning_budget,
) )
if not cleaned_content:
logger.warning( # Если основная модель не смогла ответить — пробуем fallback
"LLM returned empty content. finish_reason=%s disable_thinking=%s raw=%s", if LLAMA_FALLBACK_API_URL and (not result or _CANT_ANSWER_RE.search(result)):
finish_reason, fallback_model = LLAMA_FALLBACK_MODEL or LLAMA_MODEL
disable_thinking, logger.info("Primary model couldn't answer, trying fallback. primary_result=%s", _clip_text(result, 100))
_clip_text(raw_text, 300), try:
fallback_result = await _call_single_model(
messages,
api_url=LLAMA_FALLBACK_API_URL,
api_key=LLAMA_FALLBACK_API_KEY,
model=fallback_model,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
disable_thinking=disable_thinking,
reasoning_budget=reasoning_budget,
) )
return cleaned_content if fallback_result:
return f"{fallback_result}\n\n<i>🤖 {fallback_model}</i>"
except Exception:
logger.exception("Fallback model also failed")
if not result:
logger.warning("LLM returned empty content. model=%s", LLAMA_MODEL)
return result
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:
@ -711,6 +715,8 @@ 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:
@ -736,6 +742,8 @@ 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,
) )
@ -813,13 +821,55 @@ 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 cleaned return _md_to_tg_html(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:
@ -844,8 +894,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 = 120 if reason == "autonomous" else 220 max_tokens = 180 if reason == "autonomous" else 512
temperature = 0.9 if reason == "autonomous" else 0.8 temperature = 0.85 if reason == "autonomous" else 0.7
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")
@ -854,7 +904,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.9, top_p=0.95,
) )
except Exception: except Exception:
logger.exception("Chat reply generation failed") logger.exception("Chat reply generation failed")
@ -871,7 +921,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=None) await message.reply(normalized_response, parse_mode="HTML")
return True return True
@ -944,13 +994,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=260, max_tokens=600,
temperature=0.8, temperature=0.7,
top_p=0.9, top_p=0.95,
) )
except Exception: except Exception:
logger.exception("Photo analysis failed") logger.exception("Photo analysis failed")
await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode=None) await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode="HTML")
return True return True
normalized_response = _normalize_reply(response) normalized_response = _normalize_reply(response)
@ -964,7 +1014,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=None) await message.reply(normalized_response, parse_mode="HTML")
return True return True
@ -1013,11 +1063,13 @@ 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=SYSTEM_PROMPT, system_prompt=TALK_SYSTEM_PROMPT,
user_id=user_id, user_id=user_id,
max_tokens=220, max_tokens=TALK_MAX_TOKENS,
temperature=0.8, temperature=0.7,
top_p=0.9, top_p=0.95,
disable_thinking=False,
reasoning_budget=TALK_THINKING_BUDGET,
) )
except Exception: except Exception:
logger.exception("Talk command generation failed") logger.exception("Talk command generation failed")
@ -1042,7 +1094,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=None) await message.reply(normalized_response, parse_mode="HTML")
_init_db() _init_db()

View file

@ -4,12 +4,14 @@ 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 RUN mkdir -p /db /app/data
CMD ["python", "main.py"] CMD ["python", "main.py"]

View file

@ -63,6 +63,7 @@ docker compose down
- `/bet [спорт] <номер> <исход> <ставка>` — поставить см на матч. - `/bet [спорт] <номер> <исход> <ставка>` — поставить см на матч.
- `/mybets` — мои активные ставки. - `/mybets` — мои активные ставки.
- `/svodka` — сводка СВО. - `/svodka` — сводка СВО.
- `/uwu [теги]` — поиск случайной пикчи/гифки на e621 (по умолчанию sfw, можно указать свои теги).
## Экономика ZOV OS 💰 ## Экономика ZOV OS 💰

View file

@ -7,10 +7,9 @@ from zoneinfo import ZoneInfo
BASE_DIR = Path(__file__).parent BASE_DIR = Path(__file__).parent
FONT_PATH = BASE_DIR / "impact.ttf" FONT_PATH = BASE_DIR / "impact.ttf"
DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow") DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow")
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
# --- Настройки /penis --- # --- Настройки /penis ---
PENIS_DB_PATH = "/db/penis_stats.sqlite3"
CHAT_HISTORY_DB_PATH = "/db/chat_history.sqlite3"
PENIS_START_LENGTH = 10.0 PENIS_START_LENGTH = 10.0
PENIS_MIN_DELTA = 0.1 PENIS_MIN_DELTA = 0.1
PENIS_MAX_DELTA = 7.0 PENIS_MAX_DELTA = 7.0
@ -34,17 +33,10 @@ 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"
ODDS_SPORTS = {
"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_MAX_AMOUNT = 3.0
BET_DB_PATH = "/db/bets.sqlite3" ODDS_SPORTS = {
"dota2": "esports_dota2",
}
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно) ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
# --- Nude --- # --- Nude ---
@ -221,8 +213,8 @@ def get_hint_text(templates_str: str) -> str:
"• /talk [текст] — ИИ базарит на фене.\n" "• /talk [текст] — ИИ базарит на фене.\n"
"• /penis_casino [ставка] — казино на размер.\n" "• /penis_casino [ставка] — казино на размер.\n"
"• /gadanie [тема] — гадание на фене с мемом.\n" "• /gadanie [тема] — гадание на фене с мемом.\n"
"• /matches [epl|ucl|laliga|bundesliga|seriea|ligue1|europa] — матчи для ставок.\n" "• /matches — матчи для ставок.\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 Normal file
View file

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

View file

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

View file

@ -1,13 +1,20 @@
import time as _time
import sqlite3
import logging import logging
from datetime import datetime, timedelta import time as _time
from pathlib import Path import hashlib
import random
import urllib.request
import json
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__)
@ -17,18 +24,17 @@ _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:
Path(config.BET_DB_PATH).parent.mkdir(parents=True, exist_ok=True) with get_conn() as conn:
with sqlite3.connect(config.BET_DB_PATH) as conn: cur = conn.cursor()
conn.execute( cur.execute('''
"""
CREATE TABLE IF NOT EXISTS bets ( CREATE TABLE IF NOT EXISTS bets (
id INTEGER PRIMARY KEY AUTOINCREMENT, id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL, user_id BIGINT NOT NULL,
match_id TEXT NOT NULL, match_id TEXT NOT NULL,
sport TEXT NOT NULL, sport TEXT NOT NULL,
home_team TEXT NOT NULL, home_team TEXT NOT NULL,
@ -38,25 +44,15 @@ def _init_bets_db() -> None:
odds REAL NOT NULL, odds REAL NOT NULL,
status TEXT DEFAULT 'pending', status TEXT DEFAULT 'pending',
payout REAL DEFAULT 0, payout REAL DEFAULT 0,
created_ts INTEGER, created_ts BIGINT,
resolved_ts INTEGER resolved_ts BIGINT
) )
""" ''')
) cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_user ON bets(user_id, status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_user ON bets(user_id, status)") cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_match ON bets(match_id, status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_match ON bets(match_id, status)") cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_created ON bets(created_ts)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_created ON bets(created_ts)") cur.execute("ALTER TABLE bets ADD COLUMN IF NOT EXISTS payout REAL DEFAULT 0")
cur.execute("ALTER TABLE bets ADD COLUMN IF NOT EXISTS resolved_ts BIGINT")
# Миграция: добавить столбцы если их нет
cursor = conn.execute("PRAGMA table_info(bets)")
columns = {row[1] for row in cursor.fetchall()}
if "payout" not in columns:
conn.execute("ALTER TABLE bets ADD COLUMN payout REAL DEFAULT 0")
if "resolved_ts" not in columns:
conn.execute("ALTER TABLE bets ADD COLUMN resolved_ts INTEGER")
conn.commit()
try: try:
@ -79,92 +75,119 @@ def get_user_sport_context(user_id: int) -> str | None:
async def _fetch_available_sports() -> list[dict]: async def _fetch_available_sports() -> list[dict]:
"""Получить список доступных видов спорта из API.""" return [{"key": "dota2", "title": "🎮 Dota 2", "active": True}]
global _sports_cache
async def fetch_liquipedia_matches() -> list[dict]:
global _matches_cache
now = _time.time() now = _time.time()
if _sports_cache and (now - _sports_cache[0]) < 3600: cached = _matches_cache.get("liquipedia_all")
return _sports_cache[1] if cached and (now - cached[0]) < _LIQUIPEDIA_CACHE_TTL:
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]
if not config.ODDS_API_KEY: url = "https://liquipedia.net/dota2/api.php?action=parse&page=Liquipedia:Matches&format=json"
logger.warning("ODDS_API_KEY не задан, матчи недоступны") headers = {
return [] "User-Agent": "Dota2BettingTelegramBot/1.0 (contact: danil@example.com)",
"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, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp: async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200: if resp.status != 200:
body = await resp.text() logger.warning(f"Liquipedia API returned {resp.status}")
logger.warning(f"Odds API returned {resp.status} for {sport_key}: {body[:300]}")
return cached[1] if cached else []
data = await resp.json()
logger.info(f"Odds API for {sport_key}: received {len(data)} events")
except Exception:
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): data = await resp.json()
logger.warning(f"Odds API returned non-list for {sport_key}: {type(data)}") html_content = data['parse']['text']['*']
return cached[1] if cached else []
soup = BeautifulSoup(html_content, "lxml")
match_divs = soup.find_all("div", class_="match-info")
matches = [] matches = []
for event in data: for idx, div in enumerate(match_divs):
best_odds = {} # Tournament
for bookmaker in event.get("bookmakers", []): tournament_div = div.find("div", class_="match-info-tournament")
for market in bookmaker.get("markets", []): tournament = tournament_div.get_text(strip=True) if tournament_div else "Unknown Tournament"
if market.get("key") == "h2h":
for outcome in market.get("outcomes", []): # Teams
name = outcome.get("name", "") opponents = div.find_all("div", class_="match-info-header-opponent")
price = outcome.get("price", 0) team1 = "TBD"
if name and price and (name not in best_odds or price > best_odds[name]): team2 = "TBD"
best_odds[name] = price 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)
if len(best_odds) >= 2:
matches.append({ matches.append({
"id": event.get("id", ""), "id": match_id,
"home": event.get("home_team", ""), "home": team1,
"away": event.get("away_team", ""), "away": team2,
"commence": event.get("commence_time", ""), "commence": datetime.fromtimestamp(int(timestamp)).isoformat() + "Z" if timestamp else "",
"odds": best_odds, "timestamp": timestamp,
"completed": completed,
"winner": winner,
"odds": {
team1: odds_home,
team2: odds_away
},
"tournament": tournament
}) })
_matches_cache[sport_key] = (now, matches) _matches_cache["liquipedia_all"] = (now, matches)
logger.info(f"Parsed {len(matches)} matches for {sport_key}") logger.info(f"Scraped and cached {len(matches)} matches from Liquipedia")
return matches return matches
except Exception:
logger.exception("Failed to fetch matches from Liquipedia")
return cached[1] if cached else []
async def fetch_matches(sport_key: str = "esports_dota2") -> list[dict]:
all_matches = await fetch_liquipedia_matches()
# Return only active (not completed) matches for betting
return [m for m in all_matches if not m["completed"]]
def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]: def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]:
@ -180,20 +203,7 @@ 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
@ -216,11 +226,6 @@ 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"])
@ -260,122 +265,69 @@ 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(f"Ставка: /bet {sport_alias} [номер] [исход] [ставка]") lines.append("Ставка: /bet [номер] [исход] [ставка]")
lines.append(f"Коротко после /matches {sport_alias}: /bet [номер] [исход] [ставка]") lines.append("Исход: 1/2. Можно писать ещё название команды или коэффициент.")
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:
labels = { return "🎮 Dota 2"
"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:
if not config.ODDS_API_KEY: matches = await fetch_matches()
return "⚠️ ODDS_API_KEY не задан. Ставки недоступны." return format_matches(matches, "dota2", "🎮 Dota 2")
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:
sport_key = config.ODDS_SPORTS.get(sport_alias) matches = await fetch_matches()
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) -> str: def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> tuple[bool, str]:
current = get_user_length(user_id) current = get_user_length(user_id)
if current is None: if current is None:
return "Сначала заведи счёт через /penis, братуха." return False, "Сначала заведи счёт через /penis, братуха."
if current <= 0: if current <= 0:
return "🚫 С кредитом ставки не принимаем." return False, "🚫 С кредитом ставки не принимаем."
if amount <= 0: if amount <= 0:
return "Ставка должна быть больше нуля." return False, "Ставка должна быть больше нуля."
if amount > config.BET_MAX_AMOUNT: if amount > config.BET_MAX_AMOUNT:
return f"Максимальная ставка — {config.BET_MAX_AMOUNT} см." return False, f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
if round(amount, 2) > round(current, 2):
if amount > current: return False, f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
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 f"Исход не найден. Доступные: {describe_match_outcomes(match)}" return False, 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 "Ошибка БД." return False, "Ошибка БД."
now_ts = int(_time.time()) now_ts = int(_time.time())
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
conn.execute( cur = conn.cursor()
"""INSERT INTO bets (user_id, match_id, sport, home_team, away_team, cur.execute('''
INSERT INTO bets
(user_id, match_id, sport, home_team, away_team,
chosen_team, amount, odds, status, payout, created_ts) chosen_team, amount, odds, status, payout, created_ts)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)""", VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', 0, %s)
(user_id, match["id"], sport, match["home"], match["away"], ''', (
chosen_team, amount, team_odds, now_ts), user_id, match["id"], sport, match["home"], match["away"],
) chosen_team, amount, team_odds, now_ts,
conn.commit() ))
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 "Ошибка БД." return False, "Ошибка БД."
potential = round(amount * team_odds, 1) potential = round(amount * team_odds, 1)
return ( return True, (
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"
@ -385,13 +337,15 @@ def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: f
def get_user_bets(user_id: int) -> str: def get_user_bets(user_id: int) -> str:
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
conn.row_factory = sqlite3.Row cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
rows = conn.execute( cur.execute('''
"SELECT * FROM bets WHERE user_id = ? AND status = 'pending' ORDER BY created_ts DESC LIMIT 10", SELECT * FROM bets
(user_id,), WHERE user_id = %s AND status = 'pending'
).fetchall() ORDER BY created_ts DESC LIMIT 10
except sqlite3.Error: ''', (user_id,))
rows = cur.fetchall()
except psycopg2.Error:
return "Ошибка БД." return "Ошибка БД."
if not rows: if not rows:
@ -413,34 +367,26 @@ def get_user_bets(user_id: int) -> str:
def get_user_bet_history(user_id: int) -> str: def get_user_bet_history(user_id: int) -> str:
"""Полная история ставок за последний месяц.""" cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
cutoff_ts = int((_time.time()) - BET_HISTORY_DAYS * 86400)
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
conn.row_factory = sqlite3.Row cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
rows = conn.execute( cur.execute('''
"""SELECT * FROM bets SELECT * FROM bets
WHERE user_id = ? AND created_ts >= ? WHERE user_id = %s AND created_ts >= %s
ORDER BY created_ts DESC ORDER BY created_ts DESC LIMIT 50
LIMIT 50""", ''', (user_id, cutoff_ts))
(user_id, cutoff_ts), rows = cur.fetchall()
).fetchall() except psycopg2.Error:
except sqlite3.Error:
logger.exception("Failed to get bet history") logger.exception("Failed to get bet history")
return "Ошибка БД." return "Ошибка БД."
if not rows: if not rows:
return "📋 За последний месяц ставок не было." return "📋 За последний месяц ставок не было."
# Считаем статистику total_bet = total_won = total_lost = 0.0
total_bet = 0.0 wins = losses = pending = 0
total_won = 0.0
total_lost = 0.0
wins = 0
losses = 0
pending = 0
lines = [f"📋 История ставок за {BET_HISTORY_DAYS} дней:\n"] lines = [f"📋 История ставок за {BET_HISTORY_DAYS} дней:\n"]
for r in rows: for r in rows:
@ -453,7 +399,6 @@ def get_user_bet_history(user_id: int) -> str:
if status == "won": if status == "won":
wins += 1 wins += 1
# Для старых ставок payout может быть 0, пересчитываем
actual_payout = payout_raw if payout_raw > 0 else round(amount * odds_val, 1) actual_payout = payout_raw if payout_raw > 0 else round(amount * odds_val, 1)
total_won += actual_payout total_won += actual_payout
status_icon = "" status_icon = ""
@ -466,8 +411,7 @@ def get_user_bet_history(user_id: int) -> str:
else: else:
pending += 1 pending += 1
status_icon = "" status_icon = ""
potential = round(amount * odds_val, 1) result_text = f"ожидание → {round(amount * odds_val, 1):.1f} см"
result_text = f"ожидание → {potential:.1f} см"
created = "" created = ""
if r["created_ts"]: if r["created_ts"]:
@ -480,10 +424,8 @@ def get_user_bet_history(user_id: int) -> str:
f" 📅 {created}" f" 📅 {created}"
) )
# Итоговая статистика
net = total_won - total_lost net = total_won - total_lost
net_sign = "+" if net >= 0 else "" net_sign = "+" if net >= 0 else ""
lines.append("") lines.append("")
lines.append("━━━ 📊 СТАТИСТИКА ━━━") lines.append("━━━ 📊 СТАТИСТИКА ━━━")
lines.append(f"🎰 Всего ставок: {len(rows)}") lines.append(f"🎰 Всего ставок: {len(rows)}")
@ -495,113 +437,115 @@ def get_user_bet_history(user_id: int) -> str:
lines.append(f"🏆 Выиграно: +{total_won:.1f} см") lines.append(f"🏆 Выиграно: +{total_won:.1f} см")
lines.append(f"💸 Проиграно: -{total_lost:.1f} см") lines.append(f"💸 Проиграно: -{total_lost:.1f} см")
lines.append(f"📈 Итого: {net_sign}{net:.1f} см") lines.append(f"📈 Итого: {net_sign}{net:.1f} см")
if wins + losses > 0: if wins + losses > 0:
winrate = wins / (wins + losses) * 100 lines.append(f"📊 Винрейт: {wins / (wins + losses) * 100:.0f}%")
lines.append(f"📊 Винрейт: {winrate:.0f}%")
return "\n".join(lines) return "\n".join(lines)
def cleanup_old_bets() -> int: def cleanup_old_bets() -> int:
"""Удалить завершённые ставки старше BET_HISTORY_DAYS дней."""
cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400) cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
cursor = conn.execute( cur = conn.cursor()
"DELETE FROM bets WHERE status != 'pending' AND created_ts < ?", cur.execute(
"DELETE FROM bets WHERE status != 'pending' AND created_ts < %s",
(cutoff_ts,), (cutoff_ts,),
) )
conn.commit() deleted = cur.rowcount
deleted = cursor.rowcount
if deleted > 0: if deleted > 0:
logger.info(f"Cleaned up {deleted} old bets") logger.info(f"Cleaned up {deleted} old bets")
return deleted return deleted
except sqlite3.Error: except psycopg2.Error:
logger.exception("Failed to cleanup old bets") logger.exception("Failed to cleanup old bets")
return 0 return 0
async def settle_bets() -> list[str]: _REFUND_AFTER_SECONDS = 7 * 86400 # авторефанд ставок старше 7 дней
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 sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
conn.row_factory = sqlite3.Row cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
pending = conn.execute( cur.execute(
"SELECT DISTINCT match_id, sport FROM bets WHERE status = 'pending'" "SELECT * FROM bets WHERE status = 'pending' AND created_ts < %s",
).fetchall() (cutoff,),
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"]
sport = row["sport"] event = match_map.get(match_id)
sport_key = config.ODDS_SPORTS.get(sport, sport) if not event or not event.get("completed"):
try:
url = f"{config.ODDS_API_BASE}/sports/{sport_key}/scores"
params = {"apiKey": config.ODDS_API_KEY, "daysFrom": 3}
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200:
logger.warning(f"Scores API returned {resp.status} for {sport_key}")
continue
scores_data = await resp.json()
except Exception:
logger.exception(f"Failed to fetch scores for {sport_key}")
continue continue
for event in scores_data: winner = event.get("winner")
if event.get("id") != match_id:
continue
if not event.get("completed"):
continue
scores = event.get("scores")
if not scores:
continue
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: if not winner:
continue continue
try: is_draw = (winner.casefold() in _DRAW_NAMES or winner == "Draw")
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()
try:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT * FROM bets WHERE match_id = %s AND status = 'pending'",
(match_id,),
)
bets = cur.fetchall()
wcur = conn.cursor()
for bet in bets: for bet in bets:
chosen = bet["chosen_team"] chosen = bet["chosen_team"]
bet_won = False
if is_draw: if is_draw:
# Ставка на ничью выигрывает
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw" bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
else: else:
bet_won = chosen == winner bet_won = chosen == winner
@ -609,60 +553,37 @@ async def settle_bets() -> list[str]:
if bet_won: if bet_won:
winnings = round(bet["amount"] * bet["odds"], 1) winnings = round(bet["amount"] * bet["odds"], 1)
update_user_length(bet["user_id"], winnings) update_user_length(bet["user_id"], winnings)
conn.execute( wcur.execute(
"UPDATE bets SET status = 'won', payout = ?, resolved_ts = ? WHERE id = ?", "UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s",
(winnings, now_ts, bet["id"]), (winnings, now_ts, bet["id"]),
) )
notifications.append( notifications.append((
f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см " bet["user_id"],
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})" 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: else:
conn.execute( wcur.execute(
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = ? WHERE id = ?", "UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s",
(now_ts, bet["id"]), (now_ts, bet["id"]),
) )
notifications.append( notifications.append((
f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см " bet["user_id"],
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})" f"❌ Ставка не сыграла\n"
) f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
conn.commit() f"📌 {bet['chosen_team']} (x{bet['odds']:.2f})\n"
except sqlite3.Error: 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") logger.exception("Failed to settle bets")
# Очистка старых ставок
cleanup_old_bets() cleanup_old_bets()
return notifications return notifications
async def debug_sports() -> str: async def debug_sports() -> str:
"""Диагностика: показать доступные виды спорта из API.""" return "🎮 Dota 2 — Scraper Liquipedia активен. Odds 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)

View file

@ -1,20 +1,20 @@
import random
import sqlite3
import logging import logging
import random
import psycopg2.extras
import config import config
from db import get_conn
from .economy import economy 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,63 +31,53 @@ def spin_slots() -> tuple[list[str], int]:
counts = {} counts = {}
for s in reels: for s in reels:
counts[s] = counts.get(s, 0) + 1 counts[s] = counts.get(s, 0) + 1
max_match = max(counts.values()) return reels, max(counts.values())
return reels, max_match
def get_user_length(user_id: int) -> float | None: def get_user_length(user_id: int) -> float | None:
try: try:
with sqlite3.connect(config.PENIS_DB_PATH) as conn: with get_conn() as conn:
row = conn.execute( cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
"SELECT length FROM penis_stats WHERE user_id = ?", cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
(user_id,), row = cur.fetchone()
).fetchone() return float(row['balance']) if row else None
return float(row[0]) if row else None except Exception:
except sqlite3.Error: logger.exception("casino: failed to get balance")
logger.exception("casino: failed to get length")
return None return None
def update_user_length(user_id: int, delta: float) -> float | None: def update_user_length(user_id: int, delta: float) -> float | None:
try: try:
with sqlite3.connect(config.PENIS_DB_PATH) as conn: with get_conn() as conn:
row = conn.execute( cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
"SELECT length FROM penis_stats WHERE user_id = ?", cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
(user_id,), row = cur.fetchone()
).fetchone()
if row is None: if row is None:
return None return None
new_length = round(float(row[0]) + delta, 1) new_balance = round(float(row['balance']) + delta, 1)
# Не даём уйти ниже 0 при проигрыше if delta < 0 and new_balance < 0:
if delta < 0 and new_length < 0: new_balance = 0.0
new_length = 0.0 wcur = conn.cursor()
conn.execute( wcur.execute(
"UPDATE penis_stats SET length = ? WHERE user_id = ?", 'UPDATE user_balances SET balance = %s WHERE user_id = %s',
(new_length, user_id), (new_balance, user_id),
) )
conn.commit() return new_balance
return new_length except Exception:
except sqlite3.Error: logger.exception("casino: failed to update balance")
logger.exception("casino: failed to update length")
return None return None
def play_casino(user_id: int, bet: float) -> str: def play_casino(user_id: int, bet: float) -> str:
current = 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)
@ -95,10 +85,7 @@ 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:
@ -107,22 +94,21 @@ def play_casino(user_id: int, bet: float) -> str:
f"🎉 ДЖЕКПОТ!!! Три одинаковых!\n" f"🎉 ДЖЕКПОТ!!! Три одинаковых!\n"
f"Множитель: x{multiplier}\n" f"Множитель: x{multiplier}\n"
f"Выигрыш: +{winnings:.1f} см\n" f"Выигрыш: +{winnings:.1f} см\n"
f"<EFBFBD> Баланс: {new_balance:.1f} см" f"💰 Баланс: {new_balance:.1f} см"
) )
return ( return (
f"🎰 {slots_display}\n\n" f"🎰 {slots_display}\n\n"
f"✅ Выигрыш! Две совпали.\n" f"✅ Выигрыш! Две совпали.\n"
f"Множитель: x{multiplier}\n" f"Множитель: x{multiplier}\n"
f"Выигрыш: +{winnings:.1f} см\n" f"Выигрыш: +{winnings:.1f} см\n"
f"<EFBFBD> Баланс: {new_balance:.1f} см" f"💰 Баланс: {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"<EFBFBD> Баланс: {new_balance:.1f} см" f"💰 Баланс: {new_balance:.1f} см"
) )

File diff suppressed because it is too large Load diff

View file

@ -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") == "e" # спойлер для explicit spoiler = post.get("rating") in ("e", "q") # спойлер для explicit и questionable
if ext in ("gif", "webm", "mp4"): if ext in ("gif", "webm", "mp4"):
# Для анимаций: отправляем caption отдельным сообщением, # Для анимаций: отправляем caption отдельным сообщением,

207
main.py
View file

@ -1,5 +1,5 @@
# Системные импорты # Системные импорты
import asyncio, json, logging, os, re, random, sqlite3, threading import asyncio, json, logging, os, re, random, shutil, 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,6 +41,7 @@ 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)
@ -139,23 +140,6 @@ class LessonSlot:
# --- Инициализация и вспомогательные функции --- # --- Инициализация и вспомогательные функции ---
def init_penis_db() -> None:
"""Инициализация БД для миниигры."""
Path(config.PENIS_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS penis_stats (
user_id INTEGER PRIMARY KEY,
display_name TEXT NOT NULL,
length REAL NOT NULL,
last_used_ts INTEGER
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_penis_stats_length ON penis_stats(length DESC)")
conn.commit()
def init_polychaetsi_storage() -> None: def init_polychaetsi_storage() -> None:
"""Инициализация json-хранилища статистики слова 'получается'.""" """Инициализация json-хранилища статистики слова 'получается'."""
path = config.POLYCHAETSI_STATS_PATH path = config.POLYCHAETSI_STATS_PATH
@ -1198,9 +1182,6 @@ async def handle_keywords(message: Message):
# Записываем каждое обычное сообщение в историю чата # Записываем каждое обычное сообщение в историю чата
await asyncio.to_thread(push_message, chat_id, "user", user_name, message.text) await asyncio.to_thread(push_message, chat_id, "user", user_name, message.text)
if chat_id in _autoreply_disabled_chats:
return
await handle_chat_message(message, store_message=False, allow_autonomous=True) await handle_chat_message(message, store_message=False, allow_autonomous=True)
async def handle_penis_casino_cmd(message: Message): async def handle_penis_casino_cmd(message: Message):
@ -1282,12 +1263,12 @@ async def handle_deposit_cmd(message: Message):
await message.reply("Сумма и дни должны быть положительными.", parse_mode=None) await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
return return
success = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days) success, error_msg = 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("Не удалось открыть вклад. Проверь баланс и минимальную сумму.", parse_mode=None) await message.reply(f"Не удалось открыть вклад. {error_msg}", parse_mode=None)
async def handle_loans_cmd(message: Message): async def handle_loans_cmd(message: Message):
"""Показать кредиты""" """Показать кредиты"""
@ -1340,7 +1321,7 @@ async def handle_transfer_cmd(message: Message):
username = parts[1].lstrip('@') username = parts[1].lstrip('@')
try: try:
amount = float(parts[2].replace(",", ".")) amount = round(float(parts[2].replace(",", ".")), 1)
except ValueError: except ValueError:
await message.reply("Сумма должна быть числом.", parse_mode=None) await message.reply("Сумма должна быть числом.", parse_mode=None)
return return
@ -1349,9 +1330,31 @@ async def handle_transfer_cmd(message: Message):
await message.reply("Сумма должна быть положительной.", parse_mode=None) await message.reply("Сумма должна быть положительной.", parse_mode=None)
return return
# Здесь нужно найти user_id по username to_user_id = await asyncio.to_thread(economy.find_user_id_by_username, username)
# Для простоты примера, пока покажем сообщение об ошибке if not to_user_id:
await message.reply("🔍 Поиск пользователя...\n(функция поиска пользователей будет добавлена)", parse_mode=None) await message.reply(f"Пользователь @{username} не найден. Он должен хотя бы раз сыграть в /penis.", 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):
"""Полное обнуление (только для админов)""" """Полное обнуление (только для админов)"""
@ -1481,37 +1484,17 @@ 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 None sport_alias = parts[1].strip().lower() if len(parts) > 1 else "dota2"
# Если лига не указана — показать список
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)
available = " / ".join(config.ODDS_SPORTS.keys()) await message.reply("Неизвестная лига. Доступно: dota2", parse_mode=None)
await message.reply(f"Неизвестная лига. Доступно: {available}", parse_mode=None)
return return
if message.from_user: if message.from_user:
if sport_alias in config.ODDS_SPORTS:
remember_user_sport_context(message.from_user.id, sport_alias) 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)
@ -1524,41 +1507,34 @@ 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"
"Коротко после /matches cs|dota|football: /bet [номер] [исход] [ставка]\n" "Или: /bet dota2 [номер] [исход] [ставка]\n"
"Исход: 1/2, для ничьи X, можно ещё название команды или коэффициент.\n" "Исход: 1/2. Можно писать ещё название команды или коэффициент.\n"
"Пример: /bet cs 1 1 0.5", "Пример: /bet 1 1 0.5",
parse_mode=None, parse_mode=None,
) )
return return
sport_alias: str | None sport_alias: str
if parts[1].lower() in config.ODDS_SPORTS: if parts[1].lower() == "dota2":
if len(parts) < 5: if len(parts) < 5:
await message.reply( await message.reply(
"Формат: /bet [спорт] [номер] [исход] [ставка]\nПример: /bet cs 1 1 0.5", "Формат: /bet dota2 [номер] [исход] [ставка]\nПример: /bet dota2 1 1 0.5",
parse_mode=None, parse_mode=None,
) )
return return
sport_alias = parts[1].lower() sport_alias = "dota2"
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 = get_user_sport_context(message.from_user.id) sport_alias = "dota2"
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, X, название команды или коэффициент.", parse_mode=None) await message.reply("Нужен исход: 1, 2, название команды или коэффициент.", parse_mode=None)
return return
try: try:
@ -1586,7 +1562,7 @@ async def handle_bet_cmd(message: Message):
) )
return return
result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount) success, 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):
@ -1619,13 +1595,98 @@ 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 note in notifications: for user_id, text in notifications:
logger.info(note) try:
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")
@ -1666,6 +1727,8 @@ 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)
# Экономические команды # Экономические команды
@ -1712,6 +1775,8 @@ 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="💵 взять кредит"),

View file

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

View file

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

View file

@ -111,10 +111,23 @@ 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")
@ -145,15 +158,16 @@ async def get_profile(user: dict = Depends(get_current_user)) -> ProfileResponse
user_id = user["user_id"] user_id = user["user_id"]
length = await asyncio.to_thread(get_user_length, user_id) length = await asyncio.to_thread(get_user_length, user_id)
# Считаем активные ставки
import sqlite3
active_bets = 0 active_bets = 0
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: from db import get_conn
row = conn.execute( with get_conn() as conn:
"SELECT COUNT(*) FROM bets WHERE user_id = ? AND status = 'pending'", cur = conn.cursor()
cur.execute(
"SELECT COUNT(*) FROM bets WHERE user_id = %s AND status = 'pending'",
(user_id,), (user_id,),
).fetchone() )
row = cur.fetchone()
active_bets = row[0] if row else 0 active_bets = row[0] if row else 0
except Exception: except Exception:
pass pass
@ -173,13 +187,7 @@ 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 = {
"epl": "⚽ EPL (Англия)", "dota2": "🎮 Dota 2",
"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)
@ -245,7 +253,9 @@ async def create_bet(
raise HTTPException(status_code=400, detail="Неверный исход ставки") raise HTTPException(status_code=400, detail="Неверный исход ставки")
# 3. Разместить ставку (синхронная функция) # 3. Разместить ставку (синхронная функция)
result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount) success, 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}
@ -286,7 +296,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 req.bet > current: if round(req.bet, 2) > round(current, 2):
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)
@ -484,17 +494,14 @@ async def proxy_shorties_media(url: str, request: Request):
if range_header: if range_header:
headers["Range"] = range_header headers["Range"] = range_header
timeout = aiohttp.ClientTimeout(total=120) session = get_http_session()
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")
@ -504,7 +511,6 @@ async def proxy_shorties_media(url: str, request: Request):
body = await resp.text() body = await resp.text()
finally: finally:
await resp.release() await resp.release()
await session.close()
def _proxy_media_url(target_url: str) -> str: def _proxy_media_url(target_url: str) -> str:
return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}" return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}"
@ -556,7 +562,6 @@ 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(),

View file

@ -34,7 +34,8 @@ 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) => {
if (video && video._hlsInstance) { _videoObserver.unobserve(video);
if (video._hlsInstance) {
try { video._hlsInstance.destroy(); } catch {} try { video._hlsInstance.destroy(); } catch {}
video._hlsInstance = null; video._hlsInstance = null;
} }
@ -83,6 +84,10 @@ 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;
@ -94,9 +99,10 @@ 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: 30, maxBufferLength: 15,
backBufferLength: 30, backBufferLength: 5,
enableWorker: true, enableWorker: true,
lowLatencyMode: false,
}); });
hls.loadSource(hlsSrc); hls.loadSource(hlsSrc);
hls.attachMedia(video); hls.attachMedia(video);
@ -104,6 +110,23 @@ 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;
@ -295,13 +318,17 @@ async function showBetHistory() {
async function renderBetting(data) { async function renderBetting(data) {
if (data?.league) { if (data?.league) {
return renderMatches(data.league, data.leagueName); return renderMatches(data.league, data.leagueName, data.isOnlyLeague);
} }
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>
@ -317,7 +344,7 @@ async function renderBetting(data) {
`); `);
item.addEventListener('click', () => { item.addEventListener('click', () => {
haptic(); haptic();
navigate('betting', { league: l.alias, leagueName: l.name }); navigate('betting', { league: l.alias, leagueName: l.name, isOnlyLeague: false });
}); });
list.appendChild(item); list.appendChild(item);
}); });
@ -326,15 +353,22 @@ async function renderBetting(data) {
} }
} }
async function renderMatches(league, leagueName) { async function renderMatches(league, leagueName, isOnlyLeague = false) {
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">${leagueName}</div> <div class="section-header" style="margin:0">${headerText}</div>
</div> </div>
${instructionsHtml}
${skeleton(3)} ${skeleton(3)}
`; `;
document.getElementById('back-btn').onclick = () => navigate('betting'); document.getElementById('back-btn').onclick = () => navigate(backTarget);
try { try {
const matches = await API.getMatches(league); const matches = await API.getMatches(league);
@ -361,10 +395,12 @@ async function renderMatches(league, leagueName) {
<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>
@ -885,7 +921,7 @@ async function loadFurtokPage(feedEl) {
const useHls = isShorties && !directMp4 && Boolean(post.hls_url); const useHls = isShorties && !directMp4 && Boolean(post.hls_url);
const videoSrc = useHls ? '' : proxyMp4; const videoSrc = useHls ? '' : proxyMp4;
const hlsSrc = isShorties ? escapeHtml(proxyHls) : ''; const hlsSrc = isShorties ? escapeHtml(proxyHls) : '';
mediaHtml = `<video src="${videoSrc}" data-direct-src="${escapeHtml(directMp4)}" data-proxy-src="${escapeHtml(proxyMp4)}" data-hls-src="${hlsSrc}"${poster} loop autoplay playsinline preload="metadata" muted></video>`; 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) : '';
@ -930,6 +966,7 @@ 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');
@ -953,14 +990,6 @@ 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) {

View file

@ -32,7 +32,9 @@ server {
} }
location /api/ { location /api/ {
proxy_pass http://webapp-api:8080; resolver 127.0.0.11 valid=10s;
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;