From 6b6c1dd15d6120fa3c0efb5a42b4f24f1d7fd3b7 Mon Sep 17 00:00:00 2001 From: Omelechko Danil Date: Mon, 23 Mar 2026 10:55:25 +0300 Subject: [PATCH] fix: matches + bet history with monthly stats --- config.py | 4 +- games/betting.py | 309 +++++++++++++++++++++++++++++++++++++++++++---- main.py | 32 ++++- 3 files changed, 320 insertions(+), 25 deletions(-) diff --git a/config.py b/config.py index 47116a7..4e36f71 100644 --- a/config.py +++ b/config.py @@ -211,7 +211,9 @@ def get_hint_text(templates_str: str) -> str: "• /gadanie [тема] — гадание на фене с мемом.\n" "• /matches [cs|dota|football] — матчи для ставок.\n" "• /bet [спорт] [номер] [исход] [ставка] — поставить см.\n" - "• /mybets — мои ставки.\n" + "• /mybets — мои активные ставки.\n" + "• /bet_history — история ставок за месяц + статистика.\n" + "• /sports_debug — диагностика API матчей.\n" "• /ebalnik — включить/выключить автоответы в чате.\n" "• /uwu [теги] — случайная картинка с e621.\n" "• /polychaetsi — топ по слову «получается».\n" diff --git a/games/betting.py b/games/betting.py index 9aaaea1..cf22ceb 100644 --- a/games/betting.py +++ b/games/betting.py @@ -1,7 +1,7 @@ import time as _time import sqlite3 import logging -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo @@ -13,9 +13,13 @@ 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) @@ -33,12 +37,25 @@ def _init_bets_db() -> None: amount REAL NOT NULL, odds REAL NOT NULL, status TEXT DEFAULT 'pending', - created_ts INTEGER + 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() @@ -58,6 +75,34 @@ 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) @@ -65,6 +110,7 @@ async def fetch_matches(sport_key: str) -> list[dict]: 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" @@ -79,11 +125,17 @@ async def fetch_matches(sport_key: str) -> list[dict]: 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"Odds 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("Failed to fetch odds") + 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 = [] @@ -91,23 +143,24 @@ async def fetch_matches(sport_key: str) -> list[dict]: best_odds = {} for bookmaker in event.get("bookmakers", []): for market in bookmaker.get("markets", []): - if market["key"] == "h2h": - for outcome in market["outcomes"]: - name = outcome["name"] - price = outcome["price"] - if name not in best_odds or price > best_odds[name]: + 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["id"], - "home": event["home_team"], - "away": event["away_team"], + "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 @@ -216,16 +269,45 @@ def _get_sport_label(key: str) -> str: 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))) - return "\n\n".join(parts) + + 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: @@ -268,8 +350,8 @@ def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: f 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, created_ts) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)""", + 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), ) @@ -304,16 +386,129 @@ def get_user_bets(user_id: int) -> str: if not rows: return "У тебя нет активных ставок." - lines = ["📋 Твои ставки:\n"] + 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} см" + 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: @@ -325,6 +520,11 @@ async def settle_bets() -> list[str]: 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"] @@ -336,9 +536,11 @@ async def settle_bets() -> list[str]: 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: @@ -353,11 +555,22 @@ async def settle_bets() -> list[str]: winner = None max_score = -1 + is_draw = False + + score_values = [] for s in scores: score_val = int(s.get("score", 0)) - if score_val > max_score: - max_score = score_val - winner = s["name"] + 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 @@ -371,16 +584,31 @@ async def settle_bets() -> list[str]: ).fetchall() for bet in bets: - if bet["chosen_team"] == winner: + 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' WHERE id = ?", (bet["id"],)) + 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' WHERE id = ?", (bet["id"],)) + 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']})" @@ -389,4 +617,39 @@ async def settle_bets() -> list[str]: 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) diff --git a/main.py b/main.py index 775b614..6e5fa5d 100644 --- a/main.py +++ b/main.py @@ -25,9 +25,11 @@ from games.fortune import generate_fortune from games.uwu import handle_uwu_cmd from games.betting import ( clear_user_sport_context, + debug_sports, describe_match_outcomes, get_formatted_matches, get_match_by_index, + get_user_bet_history, get_user_bets, get_user_sport_context, place_bet, @@ -1325,6 +1327,30 @@ async def handle_mybets_cmd(message: Message): result = await asyncio.to_thread(get_user_bets, message.from_user.id) await message.reply(result, parse_mode=None) +async def handle_bet_history_cmd(message: Message): + if not message.from_user: + return + result = await asyncio.to_thread(get_user_bet_history, message.from_user.id) + # Разделяем на части если слишком длинно + if len(result) > 4000: + parts = result.split("\n\n") + chunk = "" + for part in parts: + if len(chunk) + len(part) + 2 > 4000: + await message.reply(chunk, parse_mode=None) + chunk = part + else: + chunk = chunk + "\n\n" + part if chunk else part + if chunk: + await message.reply(chunk, parse_mode=None) + else: + await message.reply(result, parse_mode=None) + +async def handle_sports_debug_cmd(message: Message): + await message.bot.send_chat_action(chat_id=message.chat.id, action="typing") + result = await debug_sports() + await message.reply(result, parse_mode=None) + async def _settle_loop(bot: Bot): while True: await asyncio.sleep(1800) @@ -1367,6 +1393,8 @@ def main(): dp.message.register(handle_matches_cmd, Command("matches")) dp.message.register(handle_bet_cmd, Command("bet")) dp.message.register(handle_mybets_cmd, Command("mybets")) + dp.message.register(handle_bet_history_cmd, Command("bet_history")) + dp.message.register(handle_sports_debug_cmd, Command("sports_debug")) dp.message.register(handle_gen_mem, Command("gen_mem")) dp.message.register(handle_uwu_cmd, Command("uwu")) dp.message.register(handle_keywords, F.text) @@ -1393,7 +1421,9 @@ def main(): BotCommand(command="gadanie", description="гадание на фене"), BotCommand(command="matches", description="матчи для ставок"), BotCommand(command="bet", description="поставить см на матч"), - BotCommand(command="mybets", description="мои ставки"), + BotCommand(command="mybets", description="мои активные ставки"), + BotCommand(command="bet_history", description="история ставок за месяц"), + BotCommand(command="sports_debug", description="диагностика API матчей"), BotCommand(command="svodka", description="СВО: итоги"), BotCommand(command="uwu", description="Случайная картинка с e621"), ] -- 2.45.2