fix: stavki #16

Merged
q merged 1 commit from Dan4ick/bot_tg:feature/FenyaBot into main 2026-03-23 09:56:35 +00:00
3 changed files with 320 additions and 25 deletions
Showing only changes of commit 6b6c1dd15d - Show all commits

View file

@ -211,7 +211,9 @@ def get_hint_text(templates_str: str) -> str:
"• /gadanie [тема] — гадание на фене с мемом.\n" "• /gadanie [тема] — гадание на фене с мемом.\n"
"• /matches [cs|dota|football] — матчи для ставок.\n" "• /matches [cs|dota|football] — матчи для ставок.\n"
"• /bet [спорт] [номер] [исход] [ставка] — поставить см.\n" "• /bet [спорт] [номер] [исход] [ставка] — поставить см.\n"
"• /mybets — мои ставки.\n" "• /mybets — мои активные ставки.\n"
"• /bet_history — история ставок за месяц + статистика.\n"
"• /sports_debug — диагностика API матчей.\n"
"• /ebalnik — включить/выключить автоответы в чате.\n" "• /ebalnik — включить/выключить автоответы в чате.\n"
"• /uwu [теги] — случайная картинка с e621.\n" "• /uwu [теги] — случайная картинка с e621.\n"
"• /polychaetsi — топ по слову «получается».\n" "• /polychaetsi — топ по слову «получается».\n"

View file

@ -1,7 +1,7 @@
import time as _time import time as _time
import sqlite3 import sqlite3
import logging import logging
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@ -13,9 +13,13 @@ from games.casino import get_user_length, update_user_length
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_matches_cache: dict[str, tuple[float, list]] = {} _matches_cache: dict[str, tuple[float, list]] = {}
_sports_cache: tuple[float, list] | None = None
_last_viewed_sport_by_user: dict[int, str] = {} _last_viewed_sport_by_user: dict[int, str] = {}
_DRAW_NAMES = {"draw", "tie", "ничья"} _DRAW_NAMES = {"draw", "tie", "ничья"}
# Сколько дней хранить историю ставок
BET_HISTORY_DAYS = 30
def _init_bets_db() -> None: def _init_bets_db() -> None:
Path(config.BET_DB_PATH).parent.mkdir(parents=True, exist_ok=True) 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, amount REAL NOT NULL,
odds REAL NOT NULL, odds REAL NOT NULL,
status TEXT DEFAULT 'pending', 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_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_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() 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) 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]: async def fetch_matches(sport_key: str) -> list[dict]:
now = _time.time() now = _time.time()
cached = _matches_cache.get(sport_key) cached = _matches_cache.get(sport_key)
@ -65,6 +110,7 @@ async def fetch_matches(sport_key: str) -> list[dict]:
return cached[1] return cached[1]
if not config.ODDS_API_KEY: if not config.ODDS_API_KEY:
logger.warning("ODDS_API_KEY не задан, матчи недоступны")
return [] return []
url = f"{config.ODDS_API_BASE}/sports/{sport_key}/odds" 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 aiohttp.ClientSession() as session:
async with session.get(url, params=params, 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: 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 [] return cached[1] if cached else []
data = await resp.json() data = await resp.json()
logger.info(f"Odds API for {sport_key}: received {len(data)} events")
except Exception: 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 [] return cached[1] if cached else []
matches = [] matches = []
@ -91,23 +143,24 @@ async def fetch_matches(sport_key: str) -> list[dict]:
best_odds = {} best_odds = {}
for bookmaker in event.get("bookmakers", []): for bookmaker in event.get("bookmakers", []):
for market in bookmaker.get("markets", []): for market in bookmaker.get("markets", []):
if market["key"] == "h2h": if market.get("key") == "h2h":
for outcome in market["outcomes"]: for outcome in market.get("outcomes", []):
name = outcome["name"] name = outcome.get("name", "")
price = outcome["price"] price = outcome.get("price", 0)
if name not in best_odds or price > best_odds[name]: if name and price and (name not in best_odds or price > best_odds[name]):
best_odds[name] = price best_odds[name] = price
if len(best_odds) >= 2: if len(best_odds) >= 2:
matches.append({ matches.append({
"id": event["id"], "id": event.get("id", ""),
"home": event["home_team"], "home": event.get("home_team", ""),
"away": event["away_team"], "away": event.get("away_team", ""),
"commence": event.get("commence_time", ""), "commence": event.get("commence_time", ""),
"odds": best_odds, "odds": best_odds,
}) })
_matches_cache[sport_key] = (now, matches) _matches_cache[sport_key] = (now, matches)
logger.info(f"Parsed {len(matches)} matches for {sport_key}")
return matches return matches
@ -216,16 +269,45 @@ def _get_sport_label(key: str) -> str:
async def get_formatted_matches(sport_alias: str | None) -> str: async def get_formatted_matches(sport_alias: str | None) -> str:
if not config.ODDS_API_KEY:
return "⚠️ ODDS_API_KEY не задан. Ставки недоступны."
if sport_alias and sport_alias in config.ODDS_SPORTS: if sport_alias and sport_alias in config.ODDS_SPORTS:
sport_key = config.ODDS_SPORTS[sport_alias] sport_key = config.ODDS_SPORTS[sport_alias]
matches = await fetch_matches(sport_key) 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)) return format_matches(matches, sport_alias, _get_sport_label(sport_alias))
parts = [] parts = []
for alias, sport_key in config.ODDS_SPORTS.items(): for alias, sport_key in config.ODDS_SPORTS.items():
matches = await fetch_matches(sport_key) matches = await fetch_matches(sport_key)
parts.append(format_matches(matches, alias, _get_sport_label(alias))) 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: 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: with sqlite3.connect(config.BET_DB_PATH) as conn:
conn.execute( conn.execute(
"""INSERT INTO bets (user_id, match_id, sport, home_team, away_team, """INSERT INTO bets (user_id, match_id, sport, home_team, away_team,
chosen_team, amount, odds, status, created_ts) chosen_team, amount, odds, status, payout, created_ts)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)""",
(user_id, match["id"], sport, match["home"], match["away"], (user_id, match["id"], sport, match["home"], match["away"],
chosen_team, amount, team_odds, now_ts), chosen_team, amount, team_odds, now_ts),
) )
@ -304,16 +386,129 @@ def get_user_bets(user_id: int) -> str:
if not rows: if not rows:
return "У тебя нет активных ставок." return "У тебя нет активных ставок."
lines = ["📋 Твои ставки:\n"] lines = ["📋 Твои активные ставки:\n"]
for r in rows: for r in rows:
potential = round(r["amount"] * r["odds"], 1) 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( lines.append(
f"{r['home_team']} vs {r['away_team']}\n" 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) 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]: async def settle_bets() -> list[str]:
notifications = [] notifications = []
try: try:
@ -325,6 +520,11 @@ async def settle_bets() -> list[str]:
except sqlite3.Error: except sqlite3.Error:
return notifications return notifications
if not pending:
return notifications
now_ts = int(_time.time())
for row in pending: for row in pending:
match_id = row["match_id"] match_id = row["match_id"]
sport = row["sport"] sport = row["sport"]
@ -336,9 +536,11 @@ async def settle_bets() -> list[str]:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp: async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200: if resp.status != 200:
logger.warning(f"Scores API returned {resp.status} for {sport_key}")
continue continue
scores_data = await resp.json() scores_data = await resp.json()
except Exception: except Exception:
logger.exception(f"Failed to fetch scores for {sport_key}")
continue continue
for event in scores_data: for event in scores_data:
@ -353,11 +555,22 @@ async def settle_bets() -> list[str]:
winner = None winner = None
max_score = -1 max_score = -1
is_draw = False
score_values = []
for s in scores: for s in scores:
score_val = int(s.get("score", 0)) 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: if score_val > max_score:
max_score = score_val max_score = score_val
winner = s["name"] winner = name
if not winner: if not winner:
continue continue
@ -371,16 +584,31 @@ async def settle_bets() -> list[str]:
).fetchall() ).fetchall()
for bet in bets: 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) winnings = round(bet["amount"] * bet["odds"], 1)
update_user_length(bet["user_id"], winnings) 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( notifications.append(
f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см " f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см "
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})" f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
) )
else: 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( notifications.append(
f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см " f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см "
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})" f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
@ -389,4 +617,39 @@ async def settle_bets() -> list[str]:
except sqlite3.Error: except sqlite3.Error:
logger.exception("Failed to settle bets") logger.exception("Failed to settle bets")
# Очистка старых ставок
cleanup_old_bets()
return notifications 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)

32
main.py
View file

@ -25,9 +25,11 @@ from games.fortune import generate_fortune
from games.uwu import handle_uwu_cmd from games.uwu import handle_uwu_cmd
from games.betting import ( from games.betting import (
clear_user_sport_context, clear_user_sport_context,
debug_sports,
describe_match_outcomes, describe_match_outcomes,
get_formatted_matches, get_formatted_matches,
get_match_by_index, get_match_by_index,
get_user_bet_history,
get_user_bets, get_user_bets,
get_user_sport_context, get_user_sport_context,
place_bet, 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) result = await asyncio.to_thread(get_user_bets, message.from_user.id)
await message.reply(result, parse_mode=None) 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): async def _settle_loop(bot: Bot):
while True: while True:
await asyncio.sleep(1800) await asyncio.sleep(1800)
@ -1367,6 +1393,8 @@ def main():
dp.message.register(handle_matches_cmd, Command("matches")) dp.message.register(handle_matches_cmd, Command("matches"))
dp.message.register(handle_bet_cmd, Command("bet")) dp.message.register(handle_bet_cmd, Command("bet"))
dp.message.register(handle_mybets_cmd, Command("mybets")) 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_gen_mem, Command("gen_mem"))
dp.message.register(handle_uwu_cmd, Command("uwu")) dp.message.register(handle_uwu_cmd, Command("uwu"))
dp.message.register(handle_keywords, F.text) dp.message.register(handle_keywords, F.text)
@ -1393,7 +1421,9 @@ def main():
BotCommand(command="gadanie", description="гадание на фене"), BotCommand(command="gadanie", description="гадание на фене"),
BotCommand(command="matches", description="матчи для ставок"), BotCommand(command="matches", description="матчи для ставок"),
BotCommand(command="bet", 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="svodka", description="СВО: итоги"),
BotCommand(command="uwu", description="Случайная картинка с e621"), BotCommand(command="uwu", description="Случайная картинка с e621"),
] ]