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 = { "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: 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_raw = float(r["payout"] or 0) total_bet += amount 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 = "✅" result_text = f"+{actual_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)