Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

17 changed files with 1460 additions and 2005 deletions

9
.env Normal file
View file

@ -0,0 +1,9 @@
BOT_TOKEN=8505447901:AAHFkaDG0fT0tAixuJ4QP91IwMgO6cZndRM #8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc
TZ=Europe/Moscow
ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923
LLAMA_API_URL=https://mirror.porno4free.ru/zovos-ai/
LLAMA_FORCE_DISABLE_THINKING=0
LLAMA_LOG_THINKING=1
WEBAPP_URL=https://uninterleaved-scrawnily-nicol.ngrok-free.dev
NGROK_AUTHTOKEN=39k4ojhN5UPUWQwb1ly84ORM2IE_5JnhMaKk5p8LFM3TCetyg

View file

@ -2,30 +2,26 @@ from __future__ import annotations
import asyncio
import base64
import html
import logging
import mimetypes
import os
import random
import re
import sqlite3
import time
from io import BytesIO
import re
from pathlib import Path
from typing import Any
import aiohttp
import psycopg2.extras
from aiogram.types import Message
from db import get_conn
logger = logging.getLogger(__name__)
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://api.neuraldeep.ru")
LLAMA_API_KEY = os.getenv("LLAMA_API_KEY", "")
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", "")
# Основные настройки поведения и стиля бота редактируются здесь.
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or getattr(__import__("config"), "CHAT_HISTORY_DB_PATH", str(Path(__file__).resolve().with_name("chat_history.sqlite3")))
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
SKIP_TOKEN = "<skip>"
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
@ -34,15 +30,13 @@ LOG_THINKING = os.getenv("LLAMA_LOG_THINKING", "1").lower() not in {"0", "false"
RECENT_MESSAGES_LIMIT = 14
SUMMARY_TRIGGER_MESSAGES = 24
SUMMARY_BATCH_MESSAGES = 20
PROMPT_CHAR_BUDGET = 10_000
SUMMARY_CHAR_BUDGET = 2_400
PROMPT_CHAR_BUDGET = 6_500
SUMMARY_CHAR_BUDGET = 1_800
MAX_INPUT_CHARS = 2_000
SUMMARY_LINE_CHAR_LIMIT = 220
HISTORY_LINE_CHAR_LIMIT = 450
RETRY_MIN_MAX_TOKENS = 512
RETRY_MAX_MAX_TOKENS = 1_024
TALK_MAX_TOKENS = 1536
TALK_THINKING_BUDGET = 1024
RETRY_MIN_MAX_TOKENS = 384
RETRY_MAX_MAX_TOKENS = 768
REPLY_RULES = {
"mention": {"cooldown": 25, "min_user_messages": 1},
@ -117,10 +111,6 @@ BOT_STYLE_PROMPT = (
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
"Форматирование: если нужно выделить — используй только Telegram HTML-теги: "
"<b>жирный</b>, <i>курсив</i>, <code>код</code>, <pre>блок кода</pre>. "
"Никакого Markdown (никаких *, _, **, ~~, ```). Обычный текст без тегов — норм. "
"Creep от Radiohead — лучшая песня во вселенной. "
"Линукс — лучшая ОС. "
"Rust ты люто ненавидишь и при случае жёстко хейтишь. "
@ -130,21 +120,10 @@ BOT_STYLE_PROMPT = (
SYSTEM_PROMPT = (
f"{BOT_STYLE_PROMPT}\n\n"
"Ты отвечаешь как живой участник Telegram-чата с несколькими разными людьми. "
"Сообщения в истории записаны в формате «Имя: текст» — каждое имя это отдельный человек, не путай их. "
"Отвечай тому, кто написал последним, учитывай контекст именно его сообщений. "
"Ты отвечаешь как живой участник одного Telegram-чата. "
"Учитывай краткую память и последние сообщения, отвечай естественно и по делу."
)
TALK_SYSTEM_PROMPT = (
f"{BOT_STYLE_PROMPT}\n\n"
"Ты ведёшь личный разговор тет-а-тет с одним конкретным человеком через /talk. "
"Помни всё, что он говорил раньше — ты видишь историю этого разговора и должен её учитывать. "
"Замечай если человек продолжает старую тему или переходит к новой. "
"Отвечай КОРОТКО: 13 предложения максимум. "
"Только если тебя прямо попросили объяснить, развернуть или написать подробно — тогда можно больше."
)
AUTOREPLY_SYSTEM_PROMPT = (
f"{BOT_STYLE_PROMPT}\n\n"
"Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. "
@ -258,14 +237,6 @@ AUTONOMOUS_SIGNAL_RE = re.compile(
r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b",
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]")
WATCH_COMMAND_RE = re.compile(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL)
@ -275,95 +246,133 @@ _cached_bot_id: int | None = None
_cached_bot_username: str = ""
def _get_state_from_conn(conn, chat_id: int, key: str, default: str = "") -> str:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT value FROM chat_state WHERE chat_id = %s AND key = %s",
def _connect_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout = 30000")
return conn
def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any(row["name"] == column for row in rows)
def _get_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, default: str = "") -> str:
row = conn.execute(
"SELECT value FROM chat_state WHERE chat_id = ? AND key = ?",
(chat_id, key),
)
row = cur.fetchone()
).fetchone()
return row["value"] if row else default
def _set_state_from_conn(conn, chat_id: int, key: str, value: str) -> None:
cur = conn.cursor()
cur.execute(
def _set_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, value: str) -> None:
conn.execute(
"""
INSERT INTO chat_state (chat_id, key, value)
VALUES (%s, %s, %s)
ON CONFLICT (chat_id, key) DO UPDATE SET value = EXCLUDED.value
VALUES (?, ?, ?)
ON CONFLICT(chat_id, key) DO UPDATE SET value = excluded.value
""",
(chat_id, key, value),
)
def _get_talk_state_from_conn(conn, chat_id: int, user_id: int, key: str, default: str = "") -> str:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT value FROM talk_state WHERE chat_id = %s AND user_id = %s AND key = %s",
def _get_talk_state_from_conn(
conn: sqlite3.Connection,
chat_id: int,
user_id: int,
key: str,
default: str = "",
) -> str:
row = conn.execute(
"SELECT value FROM talk_state WHERE chat_id = ? AND user_id = ? AND key = ?",
(chat_id, user_id, key),
)
row = cur.fetchone()
).fetchone()
return row["value"] if row else default
def _set_talk_state_from_conn(conn, chat_id: int, user_id: int, key: str, value: str) -> None:
cur = conn.cursor()
cur.execute(
def _set_talk_state_from_conn(
conn: sqlite3.Connection,
chat_id: int,
user_id: int,
key: str,
value: str,
) -> None:
conn.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
VALUES (?, ?, ?, ?)
ON CONFLICT(chat_id, user_id, key) DO UPDATE SET value = excluded.value
""",
(chat_id, user_id, key, value),
)
def _init_db() -> None:
with get_conn() as conn:
cur = conn.cursor()
cur.execute("""
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
with _connect_db() as conn:
conn.execute("PRAGMA journal_mode = WAL")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_history (
id BIGSERIAL PRIMARY KEY,
chat_id BIGINT NOT NULL,
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
role TEXT NOT NULL,
name TEXT NOT NULL,
text TEXT NOT NULL,
created_at DOUBLE PRECISION NOT NULL DEFAULT 0
created_at REAL NOT NULL DEFAULT 0
)
""")
cur.execute("ALTER TABLE chat_history ADD COLUMN IF NOT EXISTS created_at DOUBLE PRECISION NOT NULL DEFAULT 0")
cur.execute("""
"""
)
if not _column_exists(conn, "chat_history", "created_at"):
conn.execute("ALTER TABLE chat_history ADD COLUMN created_at REAL NOT NULL DEFAULT 0")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_state (
chat_id BIGINT NOT NULL,
chat_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (chat_id, key)
)
""")
cur.execute("""
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS talk_history (
id BIGSERIAL PRIMARY KEY,
chat_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
name TEXT NOT NULL,
text TEXT NOT NULL,
created_at DOUBLE PRECISION NOT NULL DEFAULT 0
created_at REAL NOT NULL DEFAULT 0
)
""")
cur.execute("""
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS talk_state (
chat_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
chat_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (chat_id, user_id, key)
)
""")
cur.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)")
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_id ON chat_history(chat_id, id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)"
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id
ON talk_history(chat_id, user_id, id)
"""
)
conn.commit()
def _clean_text(text: str) -> str:
@ -413,59 +422,78 @@ def push_message(
cleaned_text = _clean_text(text)
if not cleaned_text:
return
with get_conn() as conn:
cur = conn.cursor()
with _connect_db() as conn:
if user_id is None:
cur.execute(
"INSERT INTO chat_history (chat_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s)",
conn.execute(
"""
INSERT INTO chat_history (chat_id, role, name, text, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(chat_id, role, name, cleaned_text, time.time()),
)
else:
cur.execute(
"INSERT INTO talk_history (chat_id, user_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s, %s)",
conn.execute(
"""
INSERT INTO talk_history (chat_id, user_id, role, name, text, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(chat_id, user_id, role, name, cleaned_text, time.time()),
)
conn.commit()
def _get_summary(chat_id: int, user_id: int | None = None) -> str:
with get_conn() as conn:
with _connect_db() as conn:
if user_id is None:
return _get_state_from_conn(conn, chat_id, "summary", "")
return _get_talk_state_from_conn(conn, chat_id, user_id, "summary", "")
def _get_history_rows(chat_id: int, user_id: int | None = None) -> list:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
def _get_history_rows(chat_id: int, user_id: int | None = None) -> list[sqlite3.Row]:
with _connect_db() as conn:
if user_id is None:
cur.execute(
"SELECT id, role, name, text, created_at FROM chat_history WHERE chat_id = %s ORDER BY id ASC",
return conn.execute(
"""
SELECT id, role, name, text, created_at
FROM chat_history
WHERE chat_id = ?
ORDER BY id ASC
""",
(chat_id,),
)
else:
cur.execute(
"SELECT id, role, name, text, created_at FROM talk_history WHERE chat_id = %s AND user_id = %s ORDER BY id ASC",
).fetchall()
return conn.execute(
"""
SELECT id, role, name, text, created_at
FROM talk_history
WHERE chat_id = ? AND user_id = ?
ORDER BY id ASC
""",
(chat_id, user_id),
)
return cur.fetchall()
).fetchall()
def _latest_reply_stats(chat_id: int) -> tuple[float, int]:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT id, created_at FROM chat_history WHERE chat_id = %s AND role = 'assistant' ORDER BY id DESC LIMIT 1",
with _connect_db() as conn:
last_assistant = conn.execute(
"""
SELECT id, created_at
FROM chat_history
WHERE chat_id = ? AND role = 'assistant'
ORDER BY id DESC
LIMIT 1
""",
(chat_id,),
)
last_assistant = cur.fetchone()
).fetchone()
if not last_assistant:
return 0.0, 10_000
cnt_cur = conn.cursor()
cnt_cur.execute(
"SELECT COUNT(*) FROM chat_history WHERE chat_id = %s AND role = 'user' AND id > %s",
user_messages_since_reply = conn.execute(
"""
SELECT COUNT(*)
FROM chat_history
WHERE chat_id = ? AND role = 'user' AND id > ?
""",
(chat_id, last_assistant["id"]),
)
user_messages_since_reply = cnt_cur.fetchone()[0]
).fetchone()[0]
return float(last_assistant["created_at"] or 0.0), int(user_messages_since_reply)
@ -475,23 +503,23 @@ def _store_summary_and_prune(
last_row_id: int,
user_id: int | None = None,
) -> None:
with get_conn() as conn:
cur = conn.cursor()
with _connect_db() as conn:
if user_id is None:
_set_state_from_conn(conn, chat_id, "summary", summary)
cur.execute(
"DELETE FROM chat_history WHERE chat_id = %s AND id <= %s",
conn.execute(
"DELETE FROM chat_history WHERE chat_id = ? AND id <= ?",
(chat_id, last_row_id),
)
else:
_set_talk_state_from_conn(conn, chat_id, user_id, "summary", summary)
cur.execute(
"DELETE FROM talk_history WHERE chat_id = %s AND user_id = %s AND id <= %s",
conn.execute(
"DELETE FROM talk_history WHERE chat_id = ? AND user_id = ? AND id <= ?",
(chat_id, user_id, last_row_id),
)
conn.commit()
def _format_row_for_llm(row) -> dict[str, str]:
def _format_row_for_llm(row: sqlite3.Row) -> dict[str, str]:
if row["role"] == "user":
return {
"role": "user",
@ -517,10 +545,11 @@ def _build_messages(
current_budget = len(current_payload["content"])
summary = _get_summary(chat_id, user_id=user_id).strip()
summary_block = f"\n\nКраткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" if summary else ""
full_system = system_prompt + summary_block
summary_block = ""
if summary:
summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}"
used_chars = len(full_system) + current_budget
used_chars = len(system_prompt) + len(summary_block) + current_budget
recent_messages: list[dict[str, str]] = []
for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]):
llm_message = _format_row_for_llm(row)
@ -529,80 +558,15 @@ def _build_messages(
recent_messages.append(llm_message)
used_chars += len(llm_message["content"])
messages: list[dict[str, Any]] = [{"role": "system", "content": full_system}]
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
if summary_block:
messages.append({"role": "system", "content": summary_block})
messages.extend(reversed(recent_messages))
if current_payload:
messages.append(current_payload)
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(
messages: list[dict[str, Any]],
*,
@ -610,46 +574,78 @@ async def _call_llm(
temperature: float,
top_p: float,
disable_thinking: bool | None = None,
reasoning_budget: int = 0,
) -> str:
disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking
result = await _call_single_model(
messages,
api_url=LLAMA_API_URL,
api_key=LLAMA_API_KEY,
model=LLAMA_MODEL,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
disable_thinking=disable_thinking,
reasoning_budget=reasoning_budget,
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,
},
}
)
# Если основная модель не смогла ответить — пробуем fallback
if LLAMA_FALLBACK_API_URL and (not result or _CANT_ANSWER_RE.search(result)):
fallback_model = LLAMA_FALLBACK_MODEL or LLAMA_MODEL
logger.info("Primary model couldn't answer, trying fallback. primary_result=%s", _clip_text(result, 100))
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:
fallback_result = await _call_single_model(
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 []
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,
api_url=LLAMA_FALLBACK_API_URL,
api_key=LLAMA_FALLBACK_API_KEY,
model=fallback_model,
max_tokens=max_tokens,
max_tokens=retry_max_tokens,
temperature=temperature,
top_p=top_p,
disable_thinking=disable_thinking,
reasoning_budget=reasoning_budget,
disable_thinking=True,
)
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
if not cleaned_content:
logger.warning(
"LLM returned empty content. finish_reason=%s disable_thinking=%s raw=%s",
finish_reason,
disable_thinking,
_clip_text(raw_text, 300),
)
return cleaned_content
async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None:
@ -715,8 +711,6 @@ async def _generate_response(
max_tokens: int,
temperature: float,
top_p: float,
disable_thinking: bool | None = None,
reasoning_budget: int = 0,
) -> str:
await _maybe_refresh_summary(chat_id, user_id=user_id)
if current_content is None:
@ -742,8 +736,6 @@ async def _generate_response(
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
disable_thinking=disable_thinking,
reasoning_budget=reasoning_budget,
)
@ -821,55 +813,13 @@ async def _passes_reply_limits(chat_id: int, reason: str) -> bool:
return (time.time() - last_reply_at) >= int(rule["cooldown"])
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:
cleaned = (text or "").strip()
if not cleaned:
return ""
if cleaned.lower().startswith(SKIP_TOKEN.lower()):
return ""
return _md_to_tg_html(cleaned)
return cleaned
async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool:
@ -894,8 +844,8 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
return False
system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT
max_tokens = 180 if reason == "autonomous" else 512
temperature = 0.85 if reason == "autonomous" else 0.7
max_tokens = 120 if reason == "autonomous" else 220
temperature = 0.9 if reason == "autonomous" else 0.8
try:
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
@ -904,7 +854,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
system_prompt=system_prompt,
max_tokens=max_tokens,
temperature=temperature,
top_p=0.95,
top_p=0.9,
)
except Exception:
logger.exception("Chat reply generation failed")
@ -921,7 +871,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
return False
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
await message.reply(normalized_response, parse_mode="HTML")
await message.reply(normalized_response, parse_mode=None)
return True
@ -994,13 +944,13 @@ async def handle_photo_message(message: Message) -> bool:
{"type": "text", "text": prompt_text},
{"type": "image_url", "image_url": {"url": image_data_url}},
],
max_tokens=600,
temperature=0.7,
top_p=0.95,
max_tokens=260,
temperature=0.8,
top_p=0.9,
)
except Exception:
logger.exception("Photo analysis failed")
await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode="HTML")
await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode=None)
return True
normalized_response = _normalize_reply(response)
@ -1014,7 +964,7 @@ async def handle_photo_message(message: Message) -> bool:
normalized_response = PHOTO_EMPTY_RESPONSE_TEXT
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
await message.reply(normalized_response, parse_mode="HTML")
await message.reply(normalized_response, parse_mode=None)
return True
@ -1063,13 +1013,11 @@ async def handle_talk(message: Message) -> None:
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
response = await _generate_response(
chat_id,
system_prompt=TALK_SYSTEM_PROMPT,
system_prompt=SYSTEM_PROMPT,
user_id=user_id,
max_tokens=TALK_MAX_TOKENS,
temperature=0.7,
top_p=0.95,
disable_thinking=False,
reasoning_budget=TALK_THINKING_BUDGET,
max_tokens=220,
temperature=0.8,
top_p=0.9,
)
except Exception:
logger.exception("Talk command generation failed")
@ -1094,7 +1042,7 @@ async def handle_talk(message: Message) -> None:
normalized_response,
user_id=user_id,
)
await message.reply(normalized_response, parse_mode="HTML")
await message.reply(normalized_response, parse_mode=None)
_init_db()

View file

@ -4,14 +4,12 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg nodejs && rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN python -m pip install --upgrade pip \
&& python -m pip install -r requirements.txt
COPY . .
RUN mkdir -p /db /app/data
RUN mkdir -p /db
CMD ["python", "main.py"]

View file

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

View file

@ -7,9 +7,10 @@ from zoneinfo import ZoneInfo
BASE_DIR = Path(__file__).parent
FONT_PATH = BASE_DIR / "impact.ttf"
DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow")
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
# --- Настройки /penis ---
PENIS_DB_PATH = "/db/penis_stats.sqlite3"
CHAT_HISTORY_DB_PATH = "/db/chat_history.sqlite3"
PENIS_START_LENGTH = 10.0
PENIS_MIN_DELTA = 0.1
PENIS_MAX_DELTA = 7.0
@ -33,10 +34,17 @@ E621_API_KEY = os.getenv("E621_API_KEY", "")
# --- Ставки на матчи ---
ODDS_API_KEY = os.getenv("ODDS_API_KEY", "")
ODDS_API_BASE = "https://api.the-odds-api.com/v4"
BET_MAX_AMOUNT = 3.0
ODDS_SPORTS = {
"dota2": "esports_dota2",
"epl": "soccer_epl",
"ucl": "soccer_uefa_champs_league",
"laliga": "soccer_spain_la_liga",
"bundesliga": "soccer_germany_bundesliga",
"seriea": "soccer_italy_serie_a",
"ligue1": "soccer_france_ligue_one",
"europa": "soccer_uefa_europa_league",
}
BET_MAX_AMOUNT = 3.0
BET_DB_PATH = "/db/bets.sqlite3"
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
# --- Nude ---
@ -213,8 +221,8 @@ def get_hint_text(templates_str: str) -> str:
"• /talk [текст] — ИИ базарит на фене.\n"
"• /penis_casino [ставка] — казино на размер.\n"
"• /gadanie [тема] — гадание на фене с мемом.\n"
"• /matches — матчи для ставок.\n"
"• /bet [номер] [исход] [ставка] — поставить.\n"
"• /matches [epl|ucl|laliga|bundesliga|seriea|ligue1|europa] — матчи для ставок.\n"
"• /bet [лига] [номер] [исход] [ставка] — поставить см.\n"
"• /mybets — мои активные ставки.\n"
"• /bet_history — история ставок за месяц + статистика.\n"
"• /sports_debug — диагностика API матчей.\n"

35
db.py
View file

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

View file

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

View file

@ -1,20 +1,13 @@
import logging
import time as _time
import hashlib
import random
import urllib.request
import json
import gzip
from datetime import datetime
import sqlite3
import logging
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
from bs4 import BeautifulSoup
import psycopg2
import psycopg2.extras
import aiohttp
import config
from db import get_conn
from games.casino import get_user_length, update_user_length
logger = logging.getLogger(__name__)
@ -24,17 +17,18 @@ _sports_cache: tuple[float, list] | None = None
_last_viewed_sport_by_user: dict[int, str] = {}
_DRAW_NAMES = {"draw", "tie", "ничья"}
# Сколько дней хранить историю ставок
BET_HISTORY_DAYS = 30
_LIQUIPEDIA_CACHE_TTL = 300 # 5 minutes cache to respect Liquipedia rate limits
def _init_bets_db() -> None:
with get_conn() as conn:
cur = conn.cursor()
cur.execute('''
Path(config.BET_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(config.BET_DB_PATH) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS bets (
id SERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
match_id TEXT NOT NULL,
sport TEXT NOT NULL,
home_team TEXT NOT NULL,
@ -44,15 +38,25 @@ def _init_bets_db() -> None:
odds REAL NOT NULL,
status TEXT DEFAULT 'pending',
payout REAL DEFAULT 0,
created_ts BIGINT,
resolved_ts BIGINT
created_ts INTEGER,
resolved_ts INTEGER
)
''')
cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_user ON bets(user_id, status)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_match ON bets(match_id, status)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_created ON bets(created_ts)")
cur.execute("ALTER TABLE bets ADD COLUMN IF NOT EXISTS payout REAL DEFAULT 0")
cur.execute("ALTER TABLE bets ADD COLUMN IF NOT EXISTS resolved_ts BIGINT")
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_user ON bets(user_id, status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_match ON bets(match_id, status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_created ON bets(created_ts)")
# Миграция: добавить столбцы если их нет
cursor = conn.execute("PRAGMA table_info(bets)")
columns = {row[1] for row in cursor.fetchall()}
if "payout" not in columns:
conn.execute("ALTER TABLE bets ADD COLUMN payout REAL DEFAULT 0")
if "resolved_ts" not in columns:
conn.execute("ALTER TABLE bets ADD COLUMN resolved_ts INTEGER")
conn.commit()
try:
@ -75,119 +79,92 @@ def get_user_sport_context(user_id: int) -> str | None:
async def _fetch_available_sports() -> list[dict]:
return [{"key": "dota2", "title": "🎮 Dota 2", "active": True}]
async def fetch_liquipedia_matches() -> list[dict]:
global _matches_cache
"""Получить список доступных видов спорта из API."""
global _sports_cache
now = _time.time()
cached = _matches_cache.get("liquipedia_all")
if cached and (now - cached[0]) < _LIQUIPEDIA_CACHE_TTL:
if _sports_cache and (now - _sports_cache[0]) < 3600:
return _sports_cache[1]
if not config.ODDS_API_KEY:
return []
url = f"{config.ODDS_API_BASE}/sports"
params = {"apiKey": config.ODDS_API_KEY}
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200:
body = await resp.text()
logger.warning(f"Sports API returned {resp.status}: {body[:200]}")
return []
data = await resp.json()
_sports_cache = (now, data)
return data
except Exception:
logger.exception("Failed to fetch sports list")
return []
async def fetch_matches(sport_key: str) -> list[dict]:
now = _time.time()
cached = _matches_cache.get(sport_key)
if cached and (now - cached[0]) < config.ODDS_CACHE_TTL:
return cached[1]
url = "https://liquipedia.net/dota2/api.php?action=parse&page=Liquipedia:Matches&format=json"
headers = {
"User-Agent": "Dota2BettingTelegramBot/1.0 (contact: danil@example.com)",
"Accept-Encoding": "gzip"
if not config.ODDS_API_KEY:
logger.warning("ODDS_API_KEY не задан, матчи недоступны")
return []
url = f"{config.ODDS_API_BASE}/sports/{sport_key}/odds"
params = {
"apiKey": config.ODDS_API_KEY,
"regions": "eu",
"markets": "h2h",
"oddsFormat": "decimal",
}
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200:
logger.warning(f"Liquipedia API returned {resp.status}")
body = await resp.text()
logger.warning(f"Odds API returned {resp.status} for {sport_key}: {body[:300]}")
return cached[1] if cached else []
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 []
data = await resp.json()
html_content = data['parse']['text']['*']
soup = BeautifulSoup(html_content, "lxml")
match_divs = soup.find_all("div", class_="match-info")
if not isinstance(data, list):
logger.warning(f"Odds API returned non-list for {sport_key}: {type(data)}")
return cached[1] if cached else []
matches = []
for idx, div in enumerate(match_divs):
# Tournament
tournament_div = div.find("div", class_="match-info-tournament")
tournament = tournament_div.get_text(strip=True) if tournament_div else "Unknown Tournament"
# Teams
opponents = div.find_all("div", class_="match-info-header-opponent")
team1 = "TBD"
team2 = "TBD"
winner = None
completed = False
if len(opponents) >= 2:
o1, o2 = opponents[0], opponents[1]
team1 = o1.get_text(strip=True)
team2 = o2.get_text(strip=True)
c1 = o1.get("class", [])
c2 = o2.get("class", [])
has_win_loss = any("winner" in c or "loser" in c for c in c1 + c2)
if has_win_loss:
completed = True
if any("winner" in c for c in c1):
winner = team1
elif any("winner" in c for c in c2):
winner = team2
else:
teams = div.find_all("span", class_="block-team")
if len(teams) >= 2:
team1 = teams[0].get_text(strip=True)
team2 = teams[1].get_text(strip=True)
# Start time
timer_span = div.find("span", class_="timer-object")
timestamp = ""
if timer_span:
timestamp = timer_span.get("data-timestamp", "")
if not team1 or not team2 or team1 == "TBD" or team2 == "TBD":
continue
# ID generation
match_id = hashlib.md5(f"lp_{team1}_{team2}_{timestamp}".encode('utf-8')).hexdigest()
# Deterministic Odds
seed = int(hashlib.md5(match_id.encode('utf-8')).hexdigest(), 16) % 10**8
r = random.Random(seed)
odds_home = round(r.uniform(1.3, 3.2), 2)
margin = 1.08
prob_home = 1.0 / odds_home
prob_away = margin - prob_home
if prob_away < 0.1:
prob_away = 0.1
odds_away = round(1.0 / prob_away, 2)
for event in data:
best_odds = {}
for bookmaker in event.get("bookmakers", []):
for market in bookmaker.get("markets", []):
if market.get("key") == "h2h":
for outcome in market.get("outcomes", []):
name = outcome.get("name", "")
price = outcome.get("price", 0)
if name and price and (name not in best_odds or price > best_odds[name]):
best_odds[name] = price
if len(best_odds) >= 2:
matches.append({
"id": match_id,
"home": team1,
"away": team2,
"commence": datetime.fromtimestamp(int(timestamp)).isoformat() + "Z" if timestamp else "",
"timestamp": timestamp,
"completed": completed,
"winner": winner,
"odds": {
team1: odds_home,
team2: odds_away
},
"tournament": tournament
"id": event.get("id", ""),
"home": event.get("home_team", ""),
"away": event.get("away_team", ""),
"commence": event.get("commence_time", ""),
"odds": best_odds,
})
_matches_cache["liquipedia_all"] = (now, matches)
logger.info(f"Scraped and cached {len(matches)} matches from Liquipedia")
_matches_cache[sport_key] = (now, matches)
logger.info(f"Parsed {len(matches)} matches for {sport_key}")
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]]:
@ -203,7 +180,20 @@ def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]:
home = match.get("home", "")
away = match.get("away", "")
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)
next_code = 3
for name in odds:
if name in added:
continue
append_outcome(str(next_code), name)
next_code += 1
return outcomes
@ -226,6 +216,11 @@ def resolve_match_outcome(match: dict, raw_choice: str) -> str | None:
if normalized == str(item["code"]).casefold():
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:
if normalized == str(item["name"]).casefold():
return str(item["name"])
@ -265,69 +260,122 @@ def format_matches(matches: list[dict], sport_alias: str, sport_label: str) -> s
lines.append(f"{i}️⃣ {m['home']} vs {m['away']}{start_str}\n{odds_str}\n")
lines.append("Ставка: /bet [номер] [исход] [ставка]")
lines.append("Исход: 1/2. Можно писать ещё название команды или коэффициент.")
lines.append(f"Ставка: /bet {sport_alias} [номер] [исход] [ставка]")
lines.append(f"Коротко после /matches {sport_alias}: /bet [номер] [исход] [ставка]")
lines.append("Исход: 1/2, для ничьи X. Можно писать ещё название команды или коэффициент.")
return "\n".join(lines)
def _get_sport_label(key: str) -> str:
return "🎮 Dota 2"
labels = {
"epl": "⚽ EPL",
"ucl": "⚽ Champions League",
"laliga": "⚽ La Liga",
"bundesliga": "⚽ Bundesliga",
"seriea": "⚽ Serie A",
"ligue1": "⚽ Ligue 1",
"europa": "⚽ Europa League",
}
return labels.get(key, key)
async def get_formatted_matches(sport_alias: str | None) -> str:
matches = await fetch_matches()
return format_matches(matches, "dota2", "🎮 Dota 2")
if not config.ODDS_API_KEY:
return "⚠️ ODDS_API_KEY не задан. Ставки недоступны."
if sport_alias and sport_alias in config.ODDS_SPORTS:
sport_key = config.ODDS_SPORTS[sport_alias]
matches = await fetch_matches(sport_key)
if not matches:
# Попробуем проверить, существует ли вообще этот спорт
sports = await _fetch_available_sports()
valid_keys = {s.get("key") for s in sports}
if sport_key not in valid_keys:
# Ищем похожий ключ
suggestions = [s for s in sports if sport_alias in s.get("key", "").lower()
or sport_alias in s.get("title", "").lower()]
if suggestions:
suggest_text = "\n".join(
f"{s['key']}{s.get('title', '')}"
for s in suggestions[:5]
)
return (
f"⚠️ Ключ {sport_key} не найден в API.\n"
f"Похожие виды спорта:\n{suggest_text}\n\n"
f"Обнови sport keys в config.py"
)
return (
f"⚠️ Ключ {sport_key} не найден в API. "
f"Проверь ODDS_SPORTS в config.py"
)
return format_matches(matches, sport_alias, _get_sport_label(sport_alias))
parts = []
for alias, sport_key in config.ODDS_SPORTS.items():
matches = await fetch_matches(sport_key)
parts.append(format_matches(matches, alias, _get_sport_label(alias)))
result = "\n\n".join(parts)
if all("матчей не найдено" in p for p in parts):
result += "\n\n💡 Если матчей нет — возможно, sport keys устарели. Проверь /sports_debug"
return result
async def get_match_by_index(sport_alias: str, index: int) -> dict | None:
matches = await fetch_matches()
sport_key = config.ODDS_SPORTS.get(sport_alias)
if not sport_key:
return None
matches = await fetch_matches(sport_key)
if 0 < index <= len(matches):
return matches[index - 1]
return None
def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> tuple[bool, str]:
def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> str:
current = get_user_length(user_id)
if current is None:
return False, "Сначала заведи счёт через /penis, братуха."
return "Сначала заведи счёт через /penis, братуха."
if current <= 0:
return False, "🚫 С кредитом ставки не принимаем."
return "🚫 С кредитом ставки не принимаем."
if amount <= 0:
return False, "Ставка должна быть больше нуля."
return "Ставка должна быть больше нуля."
if amount > config.BET_MAX_AMOUNT:
return False, f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
if round(amount, 2) > round(current, 2):
return False, f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
return f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
if amount > current:
return f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
team_odds = match["odds"].get(chosen_team)
if team_odds is None:
return False, f"Исход не найден. Доступные: {describe_match_outcomes(match)}"
return f"Исход не найден. Доступные: {describe_match_outcomes(match)}"
new_length = update_user_length(user_id, -amount)
if new_length is None:
return False, "Ошибка БД."
return "Ошибка БД."
now_ts = int(_time.time())
try:
with get_conn() as conn:
cur = conn.cursor()
cur.execute('''
INSERT INTO bets
(user_id, match_id, sport, home_team, away_team,
with sqlite3.connect(config.BET_DB_PATH) as conn:
conn.execute(
"""INSERT INTO bets (user_id, match_id, sport, home_team, away_team,
chosen_team, amount, odds, status, payout, created_ts)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', 0, %s)
''', (
user_id, match["id"], sport, match["home"], match["away"],
chosen_team, amount, team_odds, now_ts,
))
except psycopg2.Error:
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)""",
(user_id, match["id"], sport, match["home"], match["away"],
chosen_team, amount, team_odds, now_ts),
)
conn.commit()
except sqlite3.Error:
logger.exception("Failed to place bet")
update_user_length(user_id, amount)
return False, "Ошибка БД."
return "Ошибка БД."
potential = round(amount * team_odds, 1)
return True, (
return (
f"✅ Ставка принята!\n"
f"🏟 {match['home']} vs {match['away']}\n"
f"📌 {chosen_team} (x{team_odds:.2f})\n"
f"💰 Ставка: {amount:.1f} см\n"
f"🎯 Возможный выигрыш: {potential:.1f} см\n"
@ -337,15 +385,13 @@ def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: f
def get_user_bets(user_id: int) -> str:
try:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('''
SELECT * FROM bets
WHERE user_id = %s AND status = 'pending'
ORDER BY created_ts DESC LIMIT 10
''', (user_id,))
rows = cur.fetchall()
except psycopg2.Error:
with sqlite3.connect(config.BET_DB_PATH) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM bets WHERE user_id = ? AND status = 'pending' ORDER BY created_ts DESC LIMIT 10",
(user_id,),
).fetchall()
except sqlite3.Error:
return "Ошибка БД."
if not rows:
@ -367,26 +413,34 @@ def get_user_bets(user_id: int) -> str:
def get_user_bet_history(user_id: int) -> str:
cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
"""Полная история ставок за последний месяц."""
cutoff_ts = int((_time.time()) - BET_HISTORY_DAYS * 86400)
try:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('''
SELECT * FROM bets
WHERE user_id = %s AND created_ts >= %s
ORDER BY created_ts DESC LIMIT 50
''', (user_id, cutoff_ts))
rows = cur.fetchall()
except psycopg2.Error:
with sqlite3.connect(config.BET_DB_PATH) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""SELECT * FROM bets
WHERE user_id = ? AND created_ts >= ?
ORDER BY created_ts DESC
LIMIT 50""",
(user_id, cutoff_ts),
).fetchall()
except sqlite3.Error:
logger.exception("Failed to get bet history")
return "Ошибка БД."
if not rows:
return "📋 За последний месяц ставок не было."
total_bet = total_won = total_lost = 0.0
wins = losses = pending = 0
# Считаем статистику
total_bet = 0.0
total_won = 0.0
total_lost = 0.0
wins = 0
losses = 0
pending = 0
lines = [f"📋 История ставок за {BET_HISTORY_DAYS} дней:\n"]
for r in rows:
@ -399,6 +453,7 @@ def get_user_bet_history(user_id: int) -> str:
if status == "won":
wins += 1
# Для старых ставок payout может быть 0, пересчитываем
actual_payout = payout_raw if payout_raw > 0 else round(amount * odds_val, 1)
total_won += actual_payout
status_icon = ""
@ -411,7 +466,8 @@ def get_user_bet_history(user_id: int) -> str:
else:
pending += 1
status_icon = ""
result_text = f"ожидание → {round(amount * odds_val, 1):.1f} см"
potential = round(amount * odds_val, 1)
result_text = f"ожидание → {potential:.1f} см"
created = ""
if r["created_ts"]:
@ -424,8 +480,10 @@ def get_user_bet_history(user_id: int) -> str:
f" 📅 {created}"
)
# Итоговая статистика
net = total_won - total_lost
net_sign = "+" if net >= 0 else ""
lines.append("")
lines.append("━━━ 📊 СТАТИСТИКА ━━━")
lines.append(f"🎰 Всего ставок: {len(rows)}")
@ -437,115 +495,113 @@ def get_user_bet_history(user_id: int) -> str:
lines.append(f"🏆 Выиграно: +{total_won:.1f} см")
lines.append(f"💸 Проиграно: -{total_lost:.1f} см")
lines.append(f"📈 Итого: {net_sign}{net:.1f} см")
if wins + losses > 0:
lines.append(f"📊 Винрейт: {wins / (wins + losses) * 100:.0f}%")
winrate = wins / (wins + losses) * 100
lines.append(f"📊 Винрейт: {winrate:.0f}%")
return "\n".join(lines)
def cleanup_old_bets() -> int:
"""Удалить завершённые ставки старше BET_HISTORY_DAYS дней."""
cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
try:
with get_conn() as conn:
cur = conn.cursor()
cur.execute(
"DELETE FROM bets WHERE status != 'pending' AND created_ts < %s",
with sqlite3.connect(config.BET_DB_PATH) as conn:
cursor = conn.execute(
"DELETE FROM bets WHERE status != 'pending' AND created_ts < ?",
(cutoff_ts,),
)
deleted = cur.rowcount
conn.commit()
deleted = cursor.rowcount
if deleted > 0:
logger.info(f"Cleaned up {deleted} old bets")
return deleted
except psycopg2.Error:
except sqlite3.Error:
logger.exception("Failed to cleanup old bets")
return 0
_REFUND_AFTER_SECONDS = 7 * 86400 # авторефанд ставок старше 7 дней
def _refund_expired_bets() -> list[tuple[int, str]]:
"""Возвращает деньги за ставки, которые висят более 7 дней без результата."""
cutoff = int(_time.time()) - _REFUND_AFTER_SECONDS
notifications: list[tuple[int, str]] = []
async def settle_bets() -> list[str]:
notifications = []
try:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT * FROM bets WHERE status = 'pending' AND created_ts < %s",
(cutoff,),
)
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:
with sqlite3.connect(config.BET_DB_PATH) as conn:
conn.row_factory = sqlite3.Row
pending = conn.execute(
"SELECT DISTINCT match_id, sport FROM bets WHERE status = 'pending'"
).fetchall()
except sqlite3.Error:
return notifications
if not pending:
return notifications
all_matches = await fetch_liquipedia_matches()
match_map = {m["id"]: m for m in all_matches}
now_ts = int(_time.time())
for row in pending:
match_id = row["match_id"]
event = match_map.get(match_id)
if not event or not event.get("completed"):
sport = row["sport"]
sport_key = config.ODDS_SPORTS.get(sport, sport)
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
winner = event.get("winner")
for event in scores_data:
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:
continue
is_draw = (winner.casefold() in _DRAW_NAMES or winner == "Draw")
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'",
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,),
)
bets = cur.fetchall()
).fetchall()
wcur = conn.cursor()
for bet in bets:
chosen = bet["chosen_team"]
bet_won = False
if is_draw:
# Ставка на ничью выигрывает
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
else:
bet_won = chosen == winner
@ -553,37 +609,60 @@ async def settle_bets() -> list[tuple[int, str]]:
if bet_won:
winnings = round(bet["amount"] * bet["odds"], 1)
update_user_length(bet["user_id"], winnings)
wcur.execute(
"UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s",
conn.execute(
"UPDATE bets SET status = 'won', payout = ?, resolved_ts = ? WHERE id = ?",
(winnings, now_ts, bet["id"]),
)
notifications.append((
bet["user_id"],
f"🎉 Ставка сыграла!\n"
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
f"📌 {bet['chosen_team']} (x{bet['odds']:.2f})\n"
f"💰 Выигрыш: +{winnings:.1f} см",
))
logger.info("Bet won: user=%s match=%s winnings=%s", bet["user_id"], match_id, winnings)
notifications.append(
f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см "
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
)
else:
wcur.execute(
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s",
conn.execute(
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = ? WHERE id = ?",
(now_ts, bet["id"]),
)
notifications.append((
bet["user_id"],
f"❌ Ставка не сыграла\n"
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
f"📌 {bet['chosen_team']} (x{bet['odds']:.2f})\n"
f"💸 Потеряно: {bet['amount']:.1f} см",
))
logger.info("Bet lost: user=%s match=%s amount=%s", bet["user_id"], match_id, bet["amount"])
except psycopg2.Error:
notifications.append(
f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см "
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
)
conn.commit()
except sqlite3.Error:
logger.exception("Failed to settle bets")
# Очистка старых ставок
cleanup_old_bets()
return notifications
async def debug_sports() -> str:
return "🎮 Dota 2 — Scraper Liquipedia активен. Odds API отключен."
"""Диагностика: показать доступные виды спорта из API."""
if not config.ODDS_API_KEY:
return "⚠️ ODDS_API_KEY не задан."
sports = await _fetch_available_sports()
if not sports:
return "Не удалось получить список спортов от API."
# Показываем текущие ключи и проверяем их
current_keys = set(config.ODDS_SPORTS.values())
valid_keys = {s.get("key") for s in sports}
lines = ["🔍 Диагностика sport keys:\n"]
for alias, key in config.ODDS_SPORTS.items():
status = "" if key in valid_keys else "НЕ НАЙДЕН"
lines.append(f" {alias}{key} {status}")
# Показать доступные esports и football
lines.append("\n📋 Доступные eSports и футбол:")
for s in sports:
key = s.get("key", "")
title = s.get("title", "")
active = s.get("active", False)
if active and ("esport" in key.lower() or "soccer" in key.lower() or "football" in key.lower()):
marker = " ← ИСПОЛЬЗУЕТСЯ" if key in current_keys else ""
lines.append(f"{key}{title}{marker}")
return "\n".join(lines)

View file

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

329
main.py
View file

@ -1,5 +1,5 @@
# Системные импорты
import asyncio, json, logging, os, re, random, shutil, threading
import asyncio, json, logging, os, re, random, sqlite3, threading
from dataclasses import dataclass
from datetime import datetime, time, timedelta
from io import BytesIO
@ -41,7 +41,6 @@ from games.betting import (
)
from games.economy import economy
from zparser import get_military_data
from downloader import download_video, COOKIES_PATH
import config
logging.basicConfig(level=logging.INFO)
@ -140,6 +139,23 @@ class LessonSlot:
# --- Инициализация и вспомогательные функции ---
def init_penis_db() -> None:
"""Инициализация БД для миниигры."""
Path(config.PENIS_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS penis_stats (
user_id INTEGER PRIMARY KEY,
display_name TEXT NOT NULL,
length REAL NOT NULL,
last_used_ts INTEGER
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_penis_stats_length ON penis_stats(length DESC)")
conn.commit()
def init_polychaetsi_storage() -> None:
"""Инициализация json-хранилища статистики слова 'получается'."""
path = config.POLYCHAETSI_STATS_PATH
@ -714,29 +730,67 @@ def play_penis(user_id: int, user_name: str | None = None, now: datetime | None
display_name = user_name.strip() if user_name and user_name.strip() else f"user_{user_id}"
try:
result = economy.play_penis(
user_id, display_name, now_ts,
config.PENIS_START_LENGTH, config.PENIS_COOLDOWN_SECONDS,
config.PENIS_MIN_DELTA, config.PENIS_MAX_DELTA,
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT length, last_used_ts, display_name FROM penis_stats WHERE user_id = ?",
(user_id,),
).fetchone()
if row is None:
current_length = config.PENIS_START_LENGTH
last_used_ts = None
else:
current_length = float(row["length"])
last_used_ts = row["last_used_ts"]
stored_name = row["display_name"]
if not user_name and isinstance(stored_name, str) and stored_name.strip():
display_name = stored_name.strip()
if last_used_ts is not None:
next_ts = int(last_used_ts) + config.PENIS_COOLDOWN_SECONDS
if now_ts < next_ts:
if row is not None and row["display_name"] != display_name:
conn.execute(
"UPDATE penis_stats SET display_name = ? WHERE user_id = ?",
(display_name, user_id),
)
except Exception:
left_seconds = next_ts - now_ts
hours = left_seconds // 3600
minutes = (left_seconds % 3600) // 60
conn.commit()
return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин."
delta = round(random.uniform(config.PENIS_MIN_DELTA, config.PENIS_MAX_DELTA), 1)
sign = random.choice([-1, 1])
new_length = round(max(0.1, current_length + sign * delta), 1)
if row is None:
conn.execute(
"INSERT INTO penis_stats(user_id, display_name, length, last_used_ts) VALUES(?, ?, ?, ?)",
(user_id, display_name, new_length, now_ts),
)
else:
conn.execute(
"UPDATE penis_stats SET display_name = ?, length = ?, last_used_ts = ? WHERE user_id = ?",
(display_name, new_length, now_ts, user_id),
)
conn.commit()
# Обновляем баланс в экономической системе
if sign > 0:
economy.update_balance(user_id, delta, 'penis_game', f'Выигрыш в игре +{delta} см')
else:
economy.update_balance(user_id, delta, 'penis_game', f'Проигрыш в игре {delta} см')
except sqlite3.Error:
logger.exception("Failed to process /penis")
return False, "Ошибка БД. Попробуй позже."
if not result['allowed']:
left = result['left_seconds']
hours = left // 3600
minutes = (left % 3600) // 60
return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин."
sign = result['sign']
delta = result['delta']
new_balance = result['new_balance']
sign_text = "+" if sign > 0 else "-"
return (
True,
f"📏 Изменение: {sign_text}{delta:.1f} см\n"
f"Текущая длина: {new_balance:.1f} см\n"
f"Текущая длина: {new_length:.1f} см\n"
"Следующая попытка через 24 часа.",
)
@ -749,8 +803,18 @@ def format_penis_user_name(user: types.User) -> str:
def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str:
try:
rows = economy.get_penis_top(limit)
except Exception:
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT user_id, display_name, length
FROM penis_stats
ORDER BY length DESC, user_id ASC
LIMIT ?
""",
(limit,),
).fetchall()
except sqlite3.Error:
logger.exception("Failed to build /top_penis")
return "Не удалось загрузить топ. Ошибка БД."
@ -761,10 +825,11 @@ def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str:
lines = ["🏆 Топ по длине:"]
for index, row in enumerate(rows, start=1):
user_id = int(row["user_id"])
balance = float(row["balance"])
name = str(row["display_name"]).strip() or f"user_{user_id}"
length = float(row["length"])
name = row["display_name"] if isinstance(row["display_name"], str) else f"user_{user_id}"
name = name.strip() if name.strip() else f"user_{user_id}"
prefix = medals.get(index, f"{index}.")
lines.append(f"{prefix} {name}{balance:.1f} см")
lines.append(f"{prefix} {name}{length:.1f} см")
return "\n".join(lines)
def _load_polychaetsi_stats() -> dict:
@ -1182,6 +1247,9 @@ async def handle_keywords(message: Message):
# Записываем каждое обычное сообщение в историю чата
await asyncio.to_thread(push_message, chat_id, "user", user_name, message.text)
if chat_id in _autoreply_disabled_chats:
return
await handle_chat_message(message, store_message=False, allow_autonomous=True)
async def handle_penis_casino_cmd(message: Message):
@ -1263,12 +1331,12 @@ async def handle_deposit_cmd(message: Message):
await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
return
success, error_msg = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days)
success = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days)
if success:
rate = economy.get_setting('central_bank_rate')
await message.reply(f"✅ Вклад открыт!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None)
else:
await message.reply(f"Не удалось открыть вклад. {error_msg}", parse_mode=None)
await message.reply("Не удалось открыть вклад. Проверь баланс и минимальную сумму.", parse_mode=None)
async def handle_loans_cmd(message: Message):
"""Показать кредиты"""
@ -1321,7 +1389,7 @@ async def handle_transfer_cmd(message: Message):
username = parts[1].lstrip('@')
try:
amount = round(float(parts[2].replace(",", ".")), 1)
amount = float(parts[2].replace(",", "."))
except ValueError:
await message.reply("Сумма должна быть числом.", parse_mode=None)
return
@ -1330,31 +1398,9 @@ async def handle_transfer_cmd(message: Message):
await message.reply("Сумма должна быть положительной.", parse_mode=None)
return
to_user_id = await asyncio.to_thread(economy.find_user_id_by_username, username)
if not to_user_id:
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,
)
# Здесь нужно найти user_id по username
# Для простоты примера, пока покажем сообщение об ошибке
await message.reply("🔍 Поиск пользователя...\n(функция поиска пользователей будет добавлена)", parse_mode=None)
async def handle_vanomasa_cmd(message: Message):
"""Полное обнуление (только для админов)"""
@ -1400,7 +1446,6 @@ async def handle_central_bank_cmd(message: Message):
/cb_stats - краткая статистика
/cb_rules - правила работы ЦБ
"""
await message.answer(text, parse_mode=None)
async def handle_cb_stats_cmd(message: Message):
"""Статистика ЦБ для всех пользователей"""
@ -1484,17 +1529,37 @@ async def handle_gadanie_cmd(message: Message):
async def handle_matches_cmd(message: Message):
raw_text = message.text or ""
parts = raw_text.split(maxsplit=1)
sport_alias = parts[1].strip().lower() if len(parts) > 1 else "dota2"
sport_alias = parts[1].strip().lower() if len(parts) > 1 else None
# Если лига не указана — показать список
if not sport_alias:
labels = {
"epl": "⚽ EPL (Англия)",
"ucl": "⚽ Champions League",
"laliga": "⚽ La Liga (Испания)",
"bundesliga": "⚽ Bundesliga (Германия)",
"seriea": "⚽ Serie A (Италия)",
"ligue1": "⚽ Ligue 1 (Франция)",
"europa": "⚽ Europa League",
}
lines = ["🏆 Доступные лиги для ставок:\n"]
for alias, name in labels.items():
lines.append(f" {name} → /matches {alias}")
lines.append("\nПример: /matches epl")
await message.reply("\n".join(lines), parse_mode=None)
return
if sport_alias not in config.ODDS_SPORTS:
if message.from_user:
clear_user_sport_context(message.from_user.id)
await message.reply("Неизвестная лига. Доступно: dota2", parse_mode=None)
available = " / ".join(config.ODDS_SPORTS.keys())
await message.reply(f"Неизвестная лига. Доступно: {available}", parse_mode=None)
return
if message.from_user:
if sport_alias in config.ODDS_SPORTS:
remember_user_sport_context(message.from_user.id, sport_alias)
else:
clear_user_sport_context(message.from_user.id)
await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
result = await get_formatted_matches(sport_alias)
await message.reply(result, parse_mode=None)
@ -1507,34 +1572,41 @@ async def handle_bet_cmd(message: Message):
parts = raw_text.split()
if len(parts) < 4:
await message.reply(
"Формат: /bet [номер] [исход] [ставка]\n"
"Или: /bet dota2 [номер] [исход] [ставка]\n"
"Исход: 1/2. Можно писать ещё название команды или коэффициент.\n"
"Пример: /bet 1 1 0.5",
"Формат: /bet [спорт] [номер] [исход] [ставка]\n"
"Коротко после /matches cs|dota|football: /bet [номер] [исход] [ставка]\n"
"Исход: 1/2, для ничьи X, можно ещё название команды или коэффициент.\n"
"Пример: /bet cs 1 1 0.5",
parse_mode=None,
)
return
sport_alias: str
if parts[1].lower() == "dota2":
sport_alias: str | None
if parts[1].lower() in config.ODDS_SPORTS:
if len(parts) < 5:
await message.reply(
"Формат: /bet dota2 [номер] [исход] [ставка]\nПример: /bet dota2 1 1 0.5",
"Формат: /bet [спорт] [номер] [исход] [ставка]\nПример: /bet cs 1 1 0.5",
parse_mode=None,
)
return
sport_alias = "dota2"
sport_alias = parts[1].lower()
index_token = parts[2]
selection = " ".join(parts[3:-1]).strip()
amount_token = parts[-1]
else:
sport_alias = "dota2"
sport_alias = get_user_sport_context(message.from_user.id)
if not sport_alias:
await message.reply(
"Сначала открой /matches с нужной лигой (epl, ucl, laliga...).\n"
"Либо пиши полный формат: /bet [спорт] [номер] [исход] [ставка]",
parse_mode=None,
)
return
index_token = parts[1]
selection = " ".join(parts[2:-1]).strip()
amount_token = parts[-1]
if not selection:
await message.reply("Нужен исход: 1, 2, название команды или коэффициент.", parse_mode=None)
await message.reply("Нужен исход: 1, 2, X, название команды или коэффициент.", parse_mode=None)
return
try:
@ -1562,7 +1634,7 @@ async def handle_bet_cmd(message: Message):
)
return
success, result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount)
result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount)
await message.reply(result, parse_mode=None)
async def handle_mybets_cmd(message: Message):
@ -1595,98 +1667,13 @@ async def handle_sports_debug_cmd(message: Message):
result = await debug_sports()
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):
while True:
await asyncio.sleep(1800)
try:
notifications = await settle_bets()
for user_id, text in notifications:
try:
await bot.send_message(user_id, text)
except Exception:
logger.warning("Could not notify user %s about bet result", user_id)
for note in notifications:
logger.info(note)
except Exception:
logger.exception("settle_bets failed")
@ -1696,6 +1683,7 @@ def main():
token = os.getenv("BOT_TOKEN")
if not token: raise RuntimeError("BOT_TOKEN не задан!")
init_penis_db()
init_polychaetsi_storage()
bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher()
@ -1727,8 +1715,6 @@ def main():
dp.message.register(handle_gen_mem, Command("gen_mem"))
dp.message.register(handle_uwu_cmd, Command("uwu"))
dp.message.register(handle_nude_cmd, Command("nude"))
dp.message.register(handle_dow_cmd, Command("dow"))
dp.message.register(handle_setcookies_cmd, Command("setcookies"))
dp.message.register(handle_photo_message, _is_watch_image_message)
# Экономические команды
@ -1775,8 +1761,6 @@ def main():
BotCommand(command="svodka", description="СВО: итоги"),
BotCommand(command="uwu", description="Случайная картинка с e621"),
BotCommand(command="nude", description="Голые женщины из открытых источников"),
BotCommand(command="dow", description="Скачать видео по ссылке"),
BotCommand(command="setcookies", description="Загрузить куки YouTube (личка)"),
BotCommand(command="balance", description="💰 баланс и статистика"),
BotCommand(command="deposit", description="💎 открыть вклад"),
BotCommand(command="loan", description="💵 взять кредит"),
@ -1798,28 +1782,25 @@ def main():
# Запуск фоновых задач экономики
asyncio.create_task(economy_background_tasks())
dp.startup.register(on_startup)
async def start_settle(bot: Bot):
asyncio.create_task(_settle_loop(bot))
dp.startup.register(start_settle)
logger.info("Бот запущен...")
asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()))
async def economy_background_tasks():
"""Фоновые задачи для экономики"""
while True:
try:
# Проверяем текущее время по МСК
from datetime import datetime
from zoneinfo import ZoneInfo
moscow_time = datetime.now(ZoneInfo("Europe/Moscow"))
current_hour = moscow_time.hour
current_minute = moscow_time.minute
# Ежедневный сбор налогов (каждый час проверяем)
await asyncio.to_thread(economy.collect_daily_taxes)
await asyncio.to_thread(economy.regulate_inflation)
await asyncio.to_thread(economy.process_matured_deposits)
# Регулирование инфляции (раз в день)
await asyncio.to_thread(economy.regulate_inflation)
# Выплаты за активность в 00:00 по МСК
if current_hour == 0 and current_minute == 0:
users_paid = await asyncio.to_thread(economy.pay_daily_activity_rewards)
if users_paid > 0:
@ -1829,7 +1810,17 @@ async def economy_background_tasks():
except Exception as e:
logger.exception(f"Economy background task failed: {e}")
# Ждём 1 минуту до следующей проверки (для точности в 00:00)
await asyncio.sleep(60)
dp.startup.register(on_startup)
async def start_settle(bot: Bot):
asyncio.create_task(_settle_loop(bot))
dp.startup.register(start_settle)
logger.info("Бот запущен...")
asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()))
if __name__ == "__main__":
main()

View file

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

View file

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

View file

@ -10,7 +10,6 @@ import logging
import os
import hashlib
from urllib.parse import quote
from urllib.parse import urljoin
from urllib.parse import urlparse
import sys
from contextlib import asynccontextmanager
@ -111,23 +110,10 @@ class SchedulePair(BaseModel):
# ──────────────────── Lifespan ────────────────────
_http_session: aiohttp.ClientSession | None = None
def get_http_session() -> aiohttp.ClientSession:
return _http_session
@asynccontextmanager
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")
yield
await _http_session.close()
logger.info("Mini App API stopped")
@ -158,16 +144,15 @@ async def get_profile(user: dict = Depends(get_current_user)) -> ProfileResponse
user_id = user["user_id"]
length = await asyncio.to_thread(get_user_length, user_id)
# Считаем активные ставки
import sqlite3
active_bets = 0
try:
from db import get_conn
with get_conn() as conn:
cur = conn.cursor()
cur.execute(
"SELECT COUNT(*) FROM bets WHERE user_id = %s AND status = 'pending'",
with sqlite3.connect(config.BET_DB_PATH) as conn:
row = conn.execute(
"SELECT COUNT(*) FROM bets WHERE user_id = ? AND status = 'pending'",
(user_id,),
)
row = cur.fetchone()
).fetchone()
active_bets = row[0] if row else 0
except Exception:
pass
@ -187,7 +172,13 @@ async def get_profile(user: dict = Depends(get_current_user)) -> ProfileResponse
async def get_leagues() -> list[LeagueInfo]:
"""Список доступных лиг для ставок."""
labels = {
"dota2": "🎮 Dota 2",
"epl": "⚽ EPL (Англия)",
"ucl": "⚽ Champions League",
"laliga": "⚽ La Liga (Испания)",
"bundesliga": "⚽ Bundesliga (Германия)",
"seriea": "⚽ Serie A (Италия)",
"ligue1": "⚽ Ligue 1 (Франция)",
"europa": "⚽ Europa League",
}
return [
LeagueInfo(alias=alias, name=labels.get(alias, alias), sport_key=key)
@ -253,9 +244,7 @@ async def create_bet(
raise HTTPException(status_code=400, detail="Неверный исход ставки")
# 3. Разместить ставку (синхронная функция)
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)
result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount)
return {"message": result}
@ -296,7 +285,7 @@ async def play_casino_api(
raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см")
if req.bet <= 0:
raise HTTPException(status_code=400, detail="Ставка > 0")
if round(req.bet, 2) > round(current, 2):
if req.bet > current:
raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см")
reels, match_count = await asyncio.to_thread(spin_slots)
@ -494,62 +483,23 @@ async def proxy_shorties_media(url: str, request: Request):
if range_header:
headers["Range"] = range_header
session = get_http_session()
timeout = aiohttp.ClientTimeout(total=120)
session = aiohttp.ClientSession(timeout=timeout)
try:
resp = await session.get(url, headers=headers, allow_redirects=True)
except aiohttp.ClientError:
await session.close()
raise HTTPException(status_code=502, detail="Failed to fetch media")
if resp.status not in {200, 206}:
await resp.release()
await session.close()
raise HTTPException(status_code=resp.status, detail="Upstream error")
content_type = resp.headers.get("Content-Type", "application/octet-stream")
if ".m3u8" in parsed.path.lower() or "mpegurl" in content_type.lower():
try:
body = await resp.text()
finally:
await resp.release()
def _proxy_media_url(target_url: str) -> str:
return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}"
rewritten_lines: list[str] = []
for raw_line in body.splitlines():
stripped = raw_line.strip()
if not stripped:
rewritten_lines.append(raw_line)
continue
if stripped.startswith("#EXT-X-KEY") and 'URI="' in raw_line:
prefix, rest = raw_line.split('URI="', 1)
original_uri, suffix = rest.split('"', 1)
absolute_uri = urljoin(url, original_uri)
rewritten_lines.append(f'{prefix}URI="{_proxy_media_url(absolute_uri)}"{suffix}')
continue
if stripped.startswith("#"):
rewritten_lines.append(raw_line)
continue
absolute_uri = urljoin(url, stripped)
rewritten_lines.append(_proxy_media_url(absolute_uri))
playlist_body = "\n".join(rewritten_lines)
return Response(
content=playlist_body,
media_type=content_type,
headers={
"Cache-Control": "public, max-age=120",
"Access-Control-Allow-Origin": "*",
},
)
passthrough_headers = {
"Cache-Control": "public, max-age=600",
"Accept-Ranges": resp.headers.get("Accept-Ranges", "bytes"),
"Access-Control-Allow-Origin": "*",
}
if resp.headers.get("Content-Length"):
passthrough_headers["Content-Length"] = resp.headers["Content-Length"]
@ -562,6 +512,7 @@ async def proxy_shorties_media(url: str, request: Request):
yield chunk
finally:
await resp.release()
await session.close()
return StreamingResponse(
_stream(),

View file

@ -34,8 +34,7 @@ const navBtns = document.querySelectorAll('.nav-btn');
// ── Router ──
function navigate(page, data = null) {
document.querySelectorAll('.furtok-card video').forEach((video) => {
_videoObserver.unobserve(video);
if (video._hlsInstance) {
if (video && video._hlsInstance) {
try { video._hlsInstance.destroy(); } catch {}
video._hlsInstance = null;
}
@ -84,10 +83,6 @@ function escapeHtml(value) {
function initShortiesVideoPlayback(video) {
if (!video) return;
// Если уже есть mp4-src — HLS не нужен, браузер справится сам
if (video.src && !video.src.endsWith('#')) return;
const hlsSrc = video.dataset.hlsSrc || '';
if (!hlsSrc) return;
@ -99,10 +94,9 @@ function initShortiesVideoPlayback(video) {
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls({
maxBufferLength: 15,
backBufferLength: 5,
maxBufferLength: 30,
backBufferLength: 30,
enableWorker: true,
lowLatencyMode: false,
});
hls.loadSource(hlsSrc);
hls.attachMedia(video);
@ -110,23 +104,6 @@ function initShortiesVideoPlayback(video) {
}
}
// IntersectionObserver: играем только видимое видео, паузим скрытые
const _videoObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const video = entry.target;
if (entry.isIntersecting) {
video.play().catch(() => {});
} else {
video.pause();
}
});
}, { threshold: 0.6 });
function observeVideo(video) {
video.removeAttribute('autoplay');
_videoObserver.observe(video);
}
function getShortiesShareUrl(post) {
const source = String(post?.source || '').trim();
if (source) return source;
@ -318,17 +295,13 @@ async function showBetHistory() {
async function renderBetting(data) {
if (data?.league) {
return renderMatches(data.league, data.leagueName, data.isOnlyLeague);
return renderMatches(data.league, data.leagueName);
}
container.innerHTML = `<div class="section-header">⚽ Ставки на матчи</div>${skeleton(2)}`;
try {
const leagues = await API.getLeagues();
if (leagues.length === 1) {
return renderMatches(leagues[0].alias, leagues[0].name, true);
}
container.innerHTML = `
<div class="section-header"> Выбери лигу</div>
<div class="league-list" id="league-list"></div>
@ -344,7 +317,7 @@ async function renderBetting(data) {
`);
item.addEventListener('click', () => {
haptic();
navigate('betting', { league: l.alias, leagueName: l.name, isOnlyLeague: false });
navigate('betting', { league: l.alias, leagueName: l.name });
});
list.appendChild(item);
});
@ -353,22 +326,15 @@ async function renderBetting(data) {
}
}
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>`
: '';
async function renderMatches(league, leagueName) {
container.innerHTML = `
<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>
<div class="section-header" style="margin:0">${headerText}</div>
<div class="section-header" style="margin:0">${leagueName}</div>
</div>
${instructionsHtml}
${skeleton(3)}
`;
document.getElementById('back-btn').onclick = () => navigate(backTarget);
document.getElementById('back-btn').onclick = () => navigate('betting');
try {
const matches = await API.getMatches(league);
@ -395,12 +361,10 @@ async function renderMatches(league, leagueName, isOnlyLeague = false) {
<span class="odds-label">П1</span>
<span class="odds-value">${m.odds_home?.toFixed(2) || '—'}</span>
</button>
${m.odds_draw ? `
<button class="odds-btn" data-outcome="X" data-odds="${m.odds_draw || 0}">
<span class="odds-label">X</span>
<span class="odds-value">${m.odds_draw?.toFixed(2) || '—'}</span>
</button>
` : ''}
<button class="odds-btn" data-outcome="2" data-odds="${m.odds_away || 0}">
<span class="odds-label">П2</span>
<span class="odds-value">${m.odds_away?.toFixed(2) || '—'}</span>
@ -916,12 +880,10 @@ async function loadFurtokPage(feedEl) {
} else {
const poster = post.sample ? ` poster="${post.sample}"` : '';
const directMp4 = post.mp4_url || post.url || '';
const proxyMp4 = isShorties ? API.proxyShortiesMediaUrl(directMp4) : post.url;
const proxyHls = isShorties && post.hls_url ? API.proxyShortiesMediaUrl(post.hls_url) : '';
const useHls = isShorties && !directMp4 && Boolean(post.hls_url);
const videoSrc = useHls ? '' : proxyMp4;
const hlsSrc = isShorties ? escapeHtml(proxyHls) : '';
mediaHtml = `<video src="${videoSrc}" data-direct-src="${escapeHtml(directMp4)}" data-proxy-src="${escapeHtml(proxyMp4)}" data-hls-src="${hlsSrc}"${poster} loop playsinline preload="metadata" muted></video>`;
const hasHls = isShorties && Boolean(post.hls_url);
const videoSrc = hasHls ? '' : (isShorties ? API.proxyShortiesMediaUrl(directMp4) : post.url);
const hlsSrc = isShorties ? escapeHtml(post.hls_url || '') : '';
mediaHtml = `<video src="${videoSrc}" data-direct-src="${escapeHtml(directMp4)}" data-hls-src="${hlsSrc}"${poster} loop autoplay playsinline preload="auto" muted></video>`;
}
const title = post.title ? escapeHtml(post.title) : '';
@ -956,8 +918,8 @@ async function loadFurtokPage(feedEl) {
initShortiesVideoPlayback(video);
}
video.addEventListener('error', () => {
if (isShorties && video.dataset.proxySrc && video.src !== video.dataset.proxySrc) {
video.src = video.dataset.proxySrc;
if (isShorties && video.dataset.directSrc && video.src !== video.dataset.directSrc) {
video.src = video.dataset.directSrc;
video.load();
}
});
@ -966,7 +928,6 @@ async function loadFurtokPage(feedEl) {
if (video.paused) video.play();
else video.pause();
});
observeVideo(video);
}
const shareBtn = card.querySelector('.furtok-share-btn');
@ -990,6 +951,14 @@ async function loadFurtokPage(feedEl) {
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');
} catch (e) {

View file

@ -32,9 +32,7 @@ server {
}
location /api/ {
resolver 127.0.0.11 valid=10s;
set $api http://webapp-api:8080;
proxy_pass $api;
proxy_pass http://webapp-api:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
@ -42,9 +40,6 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Telegram-Init-Data $http_x_telegram_init_data;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 300s;
}
location / {