1
0
Fork 0
forked from zovos/bot_tg
bot_tg/games/betting.py
2026-03-23 10:55:25 +03:00

655 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import time as _time
import sqlite3
import logging
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
import aiohttp
import config
from games.casino import get_user_length, update_user_length
logger = logging.getLogger(__name__)
_matches_cache: dict[str, tuple[float, list]] = {}
_sports_cache: tuple[float, list] | None = None
_last_viewed_sport_by_user: dict[int, str] = {}
_DRAW_NAMES = {"draw", "tie", "ничья"}
# Сколько дней хранить историю ставок
BET_HISTORY_DAYS = 30
def _init_bets_db() -> None:
Path(config.BET_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(config.BET_DB_PATH) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS bets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
match_id TEXT NOT NULL,
sport TEXT NOT NULL,
home_team TEXT NOT NULL,
away_team TEXT NOT NULL,
chosen_team TEXT NOT NULL,
amount REAL NOT NULL,
odds REAL NOT NULL,
status TEXT DEFAULT 'pending',
payout REAL DEFAULT 0,
created_ts INTEGER,
resolved_ts INTEGER
)
"""
)
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()
_init_bets_db()
def remember_user_sport_context(user_id: int, sport_alias: str) -> None:
if sport_alias in config.ODDS_SPORTS:
_last_viewed_sport_by_user[user_id] = sport_alias
def clear_user_sport_context(user_id: int) -> None:
_last_viewed_sport_by_user.pop(user_id, None)
def get_user_sport_context(user_id: int) -> str | None:
return _last_viewed_sport_by_user.get(user_id)
async def _fetch_available_sports() -> list[dict]:
"""Получить список доступных видов спорта из API."""
global _sports_cache
now = _time.time()
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]
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, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200:
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 []
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 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": event.get("id", ""),
"home": event.get("home_team", ""),
"away": event.get("away_team", ""),
"commence": event.get("commence_time", ""),
"odds": best_odds,
})
_matches_cache[sport_key] = (now, matches)
logger.info(f"Parsed {len(matches)} matches for {sport_key}")
return matches
def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]:
odds = match.get("odds", {})
outcomes: list[dict[str, str | float]] = []
added: set[str] = set()
def append_outcome(code: str, name: str) -> None:
if name in odds and name not in added:
outcomes.append({"code": code, "name": name, "odds": odds[name]})
added.add(name)
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
def describe_match_outcomes(match: dict) -> str:
return ", ".join(
f"{item['code']}={item['name']} x{item['odds']:.2f}"
for item in _get_match_outcomes(match)
)
def resolve_match_outcome(match: dict, raw_choice: str) -> str | None:
choice = raw_choice.strip()
if not choice:
return None
normalized = choice.casefold()
outcomes = _get_match_outcomes(match)
for item in outcomes:
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"])
try:
choice_odds = round(float(choice.replace(",", ".")), 2)
except ValueError:
return None
for item in outcomes:
if round(float(item["odds"]), 2) == choice_odds:
return str(item["name"])
return None
def format_matches(matches: list[dict], sport_alias: str, sport_label: str) -> str:
if not matches:
return f"🏟 {sport_label} [{sport_alias}] — матчей не найдено."
lines = [f"🏟 {sport_label} [{sport_alias}] — ближайшие матчи:\n"]
for i, m in enumerate(matches[:10], 1):
outcomes = _get_match_outcomes(m)
odds_str = "\n".join(
f" {item['code']}) {item['name']}: {item['odds']:.2f}"
for item in outcomes
)
start_str = ""
if m["commence"]:
try:
dt = datetime.fromisoformat(m["commence"].replace("Z", "+00:00"))
msk = dt.astimezone(ZoneInfo(config.DEFAULT_TZ))
start_str = f"\n 🕐 {msk.strftime('%d.%m %H:%M')} МСК"
except Exception:
pass
lines.append(f"{i}️⃣ {m['home']} vs {m['away']}{start_str}\n{odds_str}\n")
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:
labels = {"cs": "🎮 CS2", "dota": "🎮 Dota 2", "football": "⚽ Футбол"}
return labels.get(key, key)
async def get_formatted_matches(sport_alias: str | None) -> str:
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:
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) -> str:
current = get_user_length(user_id)
if current is None:
return "Сначала заведи счёт через /penis, братуха."
if current <= 0:
return "🚫 С кредитом ставки не принимаем."
if amount <= 0:
return "Ставка должна быть больше нуля."
if amount > config.BET_MAX_AMOUNT:
return f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
if amount > current:
return f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
team_odds = match["odds"].get(chosen_team)
if team_odds is None:
return f"Исход не найден. Доступные: {describe_match_outcomes(match)}"
new_length = update_user_length(user_id, -amount)
if new_length is None:
return "Ошибка БД."
now_ts = int(_time.time())
try:
with sqlite3.connect(config.BET_DB_PATH) as conn:
conn.execute(
"""INSERT INTO bets (user_id, match_id, sport, home_team, away_team,
chosen_team, amount, odds, status, payout, created_ts)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)""",
(user_id, match["id"], sport, match["home"], match["away"],
chosen_team, amount, team_odds, now_ts),
)
conn.commit()
except sqlite3.Error:
logger.exception("Failed to place bet")
update_user_length(user_id, amount)
return "Ошибка БД."
potential = round(amount * team_odds, 1)
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"
f"📏 Остаток: {new_length:.1f} см"
)
def get_user_bets(user_id: int) -> str:
try:
with sqlite3.connect(config.BET_DB_PATH) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM bets WHERE user_id = ? AND status = 'pending' ORDER BY created_ts DESC LIMIT 10",
(user_id,),
).fetchall()
except sqlite3.Error:
return "Ошибка БД."
if not rows:
return "У тебя нет активных ставок."
lines = ["📋 Твои активные ставки:\n"]
for r in rows:
potential = round(r["amount"] * r["odds"], 1)
created = ""
if r["created_ts"]:
dt = datetime.fromtimestamp(r["created_ts"], tz=ZoneInfo(config.DEFAULT_TZ))
created = f" 📅 {dt.strftime('%d.%m %H:%M')}\n"
lines.append(
f"{r['home_team']} vs {r['away_team']}\n"
f" {r['chosen_team']} (x{r['odds']:.2f}), {r['amount']:.1f} см → {potential:.1f} см\n"
f"{created}"
)
return "\n".join(lines)
def get_user_bet_history(user_id: int) -> str:
"""Полная история ставок за последний месяц."""
cutoff_ts = int((_time.time()) - BET_HISTORY_DAYS * 86400)
try:
with sqlite3.connect(config.BET_DB_PATH) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""SELECT * FROM bets
WHERE user_id = ? AND created_ts >= ?
ORDER BY created_ts DESC
LIMIT 50""",
(user_id, cutoff_ts),
).fetchall()
except sqlite3.Error:
logger.exception("Failed to get bet history")
return "Ошибка БД."
if not rows:
return "📋 За последний месяц ставок не было."
# Считаем статистику
total_bet = 0.0
total_won = 0.0
total_lost = 0.0
wins = 0
losses = 0
pending = 0
lines = [f"📋 История ставок за {BET_HISTORY_DAYS} дней:\n"]
for r in rows:
status = r["status"]
amount = float(r["amount"])
odds_val = float(r["odds"])
payout = float(r["payout"] or 0)
total_bet += amount
if status == "won":
wins += 1
total_won += payout
status_icon = ""
result_text = f"+{payout:.1f} см"
elif status == "lost":
losses += 1
total_lost += amount
status_icon = ""
result_text = f"-{amount:.1f} см"
else:
pending += 1
status_icon = ""
potential = round(amount * odds_val, 1)
result_text = f"ожидание → {potential:.1f} см"
created = ""
if r["created_ts"]:
dt = datetime.fromtimestamp(r["created_ts"], tz=ZoneInfo(config.DEFAULT_TZ))
created = dt.strftime('%d.%m %H:%M')
lines.append(
f"{status_icon} {r['home_team']} vs {r['away_team']}\n"
f" {r['chosen_team']} (x{odds_val:.2f}) | {amount:.1f} см | {result_text}\n"
f" 📅 {created}"
)
# Итоговая статистика
net = total_won - total_lost
net_sign = "+" if net >= 0 else ""
lines.append("")
lines.append("━━━ 📊 СТАТИСТИКА ━━━")
lines.append(f"🎰 Всего ставок: {len(rows)}")
lines.append(f"✅ Побед: {wins}")
lines.append(f"❌ Поражений: {losses}")
if pending:
lines.append(f"В ожидании: {pending}")
lines.append(f"💰 Поставлено: {total_bet:.1f} см")
lines.append(f"🏆 Выиграно: +{total_won:.1f} см")
lines.append(f"💸 Проиграно: -{total_lost:.1f} см")
lines.append(f"📈 Итого: {net_sign}{net:.1f} см")
if wins + losses > 0:
winrate = wins / (wins + losses) * 100
lines.append(f"📊 Винрейт: {winrate:.0f}%")
return "\n".join(lines)
def cleanup_old_bets() -> int:
"""Удалить завершённые ставки старше BET_HISTORY_DAYS дней."""
cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
try:
with sqlite3.connect(config.BET_DB_PATH) as conn:
cursor = conn.execute(
"DELETE FROM bets WHERE status != 'pending' AND created_ts < ?",
(cutoff_ts,),
)
conn.commit()
deleted = cursor.rowcount
if deleted > 0:
logger.info(f"Cleaned up {deleted} old bets")
return deleted
except sqlite3.Error:
logger.exception("Failed to cleanup old bets")
return 0
async def settle_bets() -> list[str]:
notifications = []
try:
with sqlite3.connect(config.BET_DB_PATH) as conn:
conn.row_factory = sqlite3.Row
pending = conn.execute(
"SELECT DISTINCT match_id, sport FROM bets WHERE status = 'pending'"
).fetchall()
except sqlite3.Error:
return notifications
if not pending:
return notifications
now_ts = int(_time.time())
for row in pending:
match_id = row["match_id"]
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
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
try:
with sqlite3.connect(config.BET_DB_PATH) as conn:
conn.row_factory = sqlite3.Row
bets = conn.execute(
"SELECT * FROM bets WHERE match_id = ? AND status = 'pending'",
(match_id,),
).fetchall()
for bet in bets:
chosen = bet["chosen_team"]
bet_won = False
if is_draw:
# Ставка на ничью выигрывает
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
else:
bet_won = chosen == winner
if bet_won:
winnings = round(bet["amount"] * bet["odds"], 1)
update_user_length(bet["user_id"], winnings)
conn.execute(
"UPDATE bets SET status = 'won', payout = ?, resolved_ts = ? WHERE id = ?",
(winnings, now_ts, bet["id"]),
)
notifications.append(
f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см "
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
)
else:
conn.execute(
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = ? WHERE id = ?",
(now_ts, bet["id"]),
)
notifications.append(
f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см "
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
)
conn.commit()
except sqlite3.Error:
logger.exception("Failed to settle bets")
# Очистка старых ставок
cleanup_old_bets()
return notifications
async def debug_sports() -> str:
"""Диагностика: показать доступные виды спорта из 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)