forked from zovos/bot_tg
Merge pull request 'feature/FenyaBot blur questionable (q) rating in /uwu command' (#36) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#36
This commit is contained in:
commit
cb1e042e64
6 changed files with 194 additions and 306 deletions
12
config.py
12
config.py
|
|
@ -35,13 +35,7 @@ ODDS_API_KEY = os.getenv("ODDS_API_KEY", "")
|
||||||
ODDS_API_BASE = "https://api.the-odds-api.com/v4"
|
ODDS_API_BASE = "https://api.the-odds-api.com/v4"
|
||||||
BET_MAX_AMOUNT = 3.0
|
BET_MAX_AMOUNT = 3.0
|
||||||
ODDS_SPORTS = {
|
ODDS_SPORTS = {
|
||||||
"epl": "soccer_epl",
|
"dota2": "esports_dota2",
|
||||||
"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",
|
|
||||||
}
|
}
|
||||||
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
|
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
|
||||||
|
|
||||||
|
|
@ -219,8 +213,8 @@ def get_hint_text(templates_str: str) -> str:
|
||||||
"• /talk [текст] — ИИ базарит на фене.\n"
|
"• /talk [текст] — ИИ базарит на фене.\n"
|
||||||
"• /penis_casino [ставка] — казино на размер.\n"
|
"• /penis_casino [ставка] — казино на размер.\n"
|
||||||
"• /gadanie [тема] — гадание на фене с мемом.\n"
|
"• /gadanie [тема] — гадание на фене с мемом.\n"
|
||||||
"• /matches [epl|ucl|laliga|bundesliga|seriea|ligue1|europa] — матчи для ставок.\n"
|
"• /matches — матчи для ставок.\n"
|
||||||
"• /bet [лига] [номер] [исход] [ставка] — поставить см.\n"
|
"• /bet [номер] [исход] [ставка] — поставить.\n"
|
||||||
"• /mybets — мои активные ставки.\n"
|
"• /mybets — мои активные ставки.\n"
|
||||||
"• /bet_history — история ставок за месяц + статистика.\n"
|
"• /bet_history — история ставок за месяц + статистика.\n"
|
||||||
"• /sports_debug — диагностика API матчей.\n"
|
"• /sports_debug — диагностика API матчей.\n"
|
||||||
|
|
|
||||||
322
games/betting.py
322
games/betting.py
|
|
@ -1,11 +1,17 @@
|
||||||
import logging
|
import logging
|
||||||
import time as _time
|
import time as _time
|
||||||
|
import hashlib
|
||||||
|
import random
|
||||||
|
import urllib.request
|
||||||
|
import json
|
||||||
|
import gzip
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import psycopg2.extras
|
import psycopg2.extras
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from db import get_conn
|
from db import get_conn
|
||||||
|
|
@ -19,6 +25,7 @@ _last_viewed_sport_by_user: dict[int, str] = {}
|
||||||
_DRAW_NAMES = {"draw", "tie", "ничья"}
|
_DRAW_NAMES = {"draw", "tie", "ничья"}
|
||||||
|
|
||||||
BET_HISTORY_DAYS = 30
|
BET_HISTORY_DAYS = 30
|
||||||
|
_LIQUIPEDIA_CACHE_TTL = 300 # 5 minutes cache to respect Liquipedia rate limits
|
||||||
|
|
||||||
|
|
||||||
def _init_bets_db() -> None:
|
def _init_bets_db() -> None:
|
||||||
|
|
@ -68,91 +75,119 @@ def get_user_sport_context(user_id: int) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_available_sports() -> list[dict]:
|
async def _fetch_available_sports() -> list[dict]:
|
||||||
global _sports_cache
|
return [{"key": "dota2", "title": "🎮 Dota 2", "active": True}]
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_liquipedia_matches() -> list[dict]:
|
||||||
|
global _matches_cache
|
||||||
now = _time.time()
|
now = _time.time()
|
||||||
if _sports_cache and (now - _sports_cache[0]) < 3600:
|
cached = _matches_cache.get("liquipedia_all")
|
||||||
return _sports_cache[1]
|
if cached and (now - cached[0]) < _LIQUIPEDIA_CACHE_TTL:
|
||||||
|
|
||||||
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]
|
return cached[1]
|
||||||
|
|
||||||
if not config.ODDS_API_KEY:
|
url = "https://liquipedia.net/dota2/api.php?action=parse&page=Liquipedia:Matches&format=json"
|
||||||
logger.warning("ODDS_API_KEY не задан, матчи недоступны")
|
headers = {
|
||||||
return []
|
"User-Agent": "Dota2BettingTelegramBot/1.0 (contact: danil@example.com)",
|
||||||
|
"Accept-Encoding": "gzip"
|
||||||
url = f"{config.ODDS_API_BASE}/sports/{sport_key}/odds"
|
|
||||||
params = {
|
|
||||||
"apiKey": config.ODDS_API_KEY,
|
|
||||||
"regions": "eu",
|
|
||||||
"markets": "h2h",
|
|
||||||
"oddsFormat": "decimal",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
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, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
body = await resp.text()
|
logger.warning(f"Liquipedia API returned {resp.status}")
|
||||||
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 []
|
return cached[1] if cached else []
|
||||||
|
|
||||||
if not isinstance(data, list):
|
data = await resp.json()
|
||||||
logger.warning(f"Odds API returned non-list for {sport_key}: {type(data)}")
|
html_content = data['parse']['text']['*']
|
||||||
return cached[1] if cached else []
|
|
||||||
|
soup = BeautifulSoup(html_content, "lxml")
|
||||||
|
match_divs = soup.find_all("div", class_="match-info")
|
||||||
|
|
||||||
matches = []
|
matches = []
|
||||||
for event in data:
|
for idx, div in enumerate(match_divs):
|
||||||
best_odds = {}
|
# Tournament
|
||||||
for bookmaker in event.get("bookmakers", []):
|
tournament_div = div.find("div", class_="match-info-tournament")
|
||||||
for market in bookmaker.get("markets", []):
|
tournament = tournament_div.get_text(strip=True) if tournament_div else "Unknown Tournament"
|
||||||
if market.get("key") == "h2h":
|
|
||||||
for outcome in market.get("outcomes", []):
|
# Teams
|
||||||
name = outcome.get("name", "")
|
opponents = div.find_all("div", class_="match-info-header-opponent")
|
||||||
price = outcome.get("price", 0)
|
team1 = "TBD"
|
||||||
if name and price and (name not in best_odds or price > best_odds[name]):
|
team2 = "TBD"
|
||||||
best_odds[name] = price
|
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)
|
||||||
|
|
||||||
if len(best_odds) >= 2:
|
|
||||||
matches.append({
|
matches.append({
|
||||||
"id": event.get("id", ""),
|
"id": match_id,
|
||||||
"home": event.get("home_team", ""),
|
"home": team1,
|
||||||
"away": event.get("away_team", ""),
|
"away": team2,
|
||||||
"commence": event.get("commence_time", ""),
|
"commence": datetime.fromtimestamp(int(timestamp)).isoformat() + "Z" if timestamp else "",
|
||||||
"odds": best_odds,
|
"timestamp": timestamp,
|
||||||
|
"completed": completed,
|
||||||
|
"winner": winner,
|
||||||
|
"odds": {
|
||||||
|
team1: odds_home,
|
||||||
|
team2: odds_away
|
||||||
|
},
|
||||||
|
"tournament": tournament
|
||||||
})
|
})
|
||||||
|
|
||||||
_matches_cache[sport_key] = (now, matches)
|
_matches_cache["liquipedia_all"] = (now, matches)
|
||||||
logger.info(f"Parsed {len(matches)} matches for {sport_key}")
|
logger.info(f"Scraped and cached {len(matches)} matches from Liquipedia")
|
||||||
return matches
|
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]]:
|
def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]:
|
||||||
|
|
@ -168,20 +203,7 @@ def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]:
|
||||||
home = match.get("home", "")
|
home = match.get("home", "")
|
||||||
away = match.get("away", "")
|
away = match.get("away", "")
|
||||||
append_outcome("1", home)
|
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)
|
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
|
return outcomes
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -204,11 +226,6 @@ def resolve_match_outcome(match: dict, raw_choice: str) -> str | None:
|
||||||
if normalized == str(item["code"]).casefold():
|
if normalized == str(item["code"]).casefold():
|
||||||
return str(item["name"])
|
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:
|
for item in outcomes:
|
||||||
if normalized == str(item["name"]).casefold():
|
if normalized == str(item["name"]).casefold():
|
||||||
return str(item["name"])
|
return str(item["name"])
|
||||||
|
|
@ -248,67 +265,22 @@ 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(f"{i}️⃣ {m['home']} vs {m['away']}{start_str}\n{odds_str}\n")
|
||||||
|
|
||||||
lines.append(f"Ставка: /bet {sport_alias} [номер] [исход] [ставка]")
|
lines.append("Ставка: /bet [номер] [исход] [ставка]")
|
||||||
lines.append(f"Коротко после /matches {sport_alias}: /bet [номер] [исход] [ставка]")
|
lines.append("Исход: 1/2. Можно писать ещё название команды или коэффициент.")
|
||||||
lines.append("Исход: 1/2, для ничьи X. Можно писать ещё название команды или коэффициент.")
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _get_sport_label(key: str) -> str:
|
def _get_sport_label(key: str) -> str:
|
||||||
labels = {
|
return "🎮 Dota 2"
|
||||||
"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:
|
async def get_formatted_matches(sport_alias: str | None) -> str:
|
||||||
if not config.ODDS_API_KEY:
|
matches = await fetch_matches()
|
||||||
return "⚠️ ODDS_API_KEY не задан. Ставки недоступны."
|
return format_matches(matches, "dota2", "🎮 Dota 2")
|
||||||
|
|
||||||
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. Проверь 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:
|
async def get_match_by_index(sport_alias: str, index: int) -> dict | None:
|
||||||
sport_key = config.ODDS_SPORTS.get(sport_alias)
|
matches = await fetch_matches()
|
||||||
if not sport_key:
|
|
||||||
return None
|
|
||||||
matches = await fetch_matches(sport_key)
|
|
||||||
if 0 < index <= len(matches):
|
if 0 < index <= len(matches):
|
||||||
return matches[index - 1]
|
return matches[index - 1]
|
||||||
return None
|
return None
|
||||||
|
|
@ -495,7 +467,7 @@ async def settle_bets() -> list[str]:
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
cur.execute("SELECT DISTINCT match_id, sport FROM bets WHERE status = 'pending'")
|
cur.execute("SELECT DISTINCT match_id FROM bets WHERE status = 'pending'")
|
||||||
pending = cur.fetchall()
|
pending = cur.fetchall()
|
||||||
except psycopg2.Error:
|
except psycopg2.Error:
|
||||||
return notifications
|
return notifications
|
||||||
|
|
@ -503,48 +475,17 @@ async def settle_bets() -> list[str]:
|
||||||
if not pending:
|
if not pending:
|
||||||
return notifications
|
return notifications
|
||||||
|
|
||||||
|
all_matches = await fetch_liquipedia_matches()
|
||||||
|
match_map = {m["id"]: m for m in all_matches}
|
||||||
now_ts = int(_time.time())
|
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"]
|
event = match_map.get(match_id)
|
||||||
sport_key = config.ODDS_SPORTS.get(sport, sport)
|
if not event or not event.get("completed"):
|
||||||
|
|
||||||
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
|
continue
|
||||||
|
|
||||||
for event in scores_data:
|
winner = event.get("winner")
|
||||||
if event.get("id") != match_id or not event.get("completed"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
scores = event.get("scores")
|
|
||||||
if not scores:
|
|
||||||
continue
|
|
||||||
|
|
||||||
score_values = [(s.get("name", ""), int(s.get("score", 0))) for s in scores]
|
|
||||||
winner = None
|
|
||||||
is_draw = False
|
|
||||||
|
|
||||||
if len(score_values) >= 2 and score_values[0][1] == score_values[1][1]:
|
|
||||||
is_draw = True
|
|
||||||
winner = "Draw"
|
|
||||||
else:
|
|
||||||
max_score = -1
|
|
||||||
for name, score_val in score_values:
|
|
||||||
if score_val > max_score:
|
|
||||||
max_score = score_val
|
|
||||||
winner = name
|
|
||||||
|
|
||||||
if not winner:
|
if not winner:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -560,10 +501,7 @@ async def settle_bets() -> list[str]:
|
||||||
wcur = conn.cursor()
|
wcur = conn.cursor()
|
||||||
for bet in bets:
|
for bet in bets:
|
||||||
chosen = bet["chosen_team"]
|
chosen = bet["chosen_team"]
|
||||||
if is_draw:
|
bet_won = (chosen == winner)
|
||||||
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
|
|
||||||
else:
|
|
||||||
bet_won = chosen == winner
|
|
||||||
|
|
||||||
if bet_won:
|
if bet_won:
|
||||||
winnings = round(bet["amount"] * bet["odds"], 1)
|
winnings = round(bet["amount"] * bet["odds"], 1)
|
||||||
|
|
@ -593,28 +531,4 @@ async def settle_bets() -> list[str]:
|
||||||
|
|
||||||
|
|
||||||
async def debug_sports() -> str:
|
async def debug_sports() -> str:
|
||||||
if not config.ODDS_API_KEY:
|
return "🎮 Dota 2 — Scraper Liquipedia активен. Odds API отключен."
|
||||||
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}")
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ async def handle_uwu_cmd(message: types.Message):
|
||||||
file_url = post["url"]
|
file_url = post["url"]
|
||||||
ext = post["ext"]
|
ext = post["ext"]
|
||||||
caption = post["caption"]
|
caption = post["caption"]
|
||||||
spoiler = post.get("rating") == "e" # спойлер для explicit
|
spoiler = post.get("rating") in ("e", "q") # спойлер для explicit и questionable
|
||||||
|
|
||||||
if ext in ("gif", "webm", "mp4"):
|
if ext in ("gif", "webm", "mp4"):
|
||||||
# Для анимаций: отправляем caption отдельным сообщением,
|
# Для анимаций: отправляем caption отдельным сообщением,
|
||||||
|
|
|
||||||
55
main.py
55
main.py
|
|
@ -1461,37 +1461,17 @@ async def handle_gadanie_cmd(message: Message):
|
||||||
async def handle_matches_cmd(message: Message):
|
async def handle_matches_cmd(message: Message):
|
||||||
raw_text = message.text or ""
|
raw_text = message.text or ""
|
||||||
parts = raw_text.split(maxsplit=1)
|
parts = raw_text.split(maxsplit=1)
|
||||||
sport_alias = parts[1].strip().lower() if len(parts) > 1 else None
|
sport_alias = parts[1].strip().lower() if len(parts) > 1 else "dota2"
|
||||||
|
|
||||||
# Если лига не указана — показать список
|
|
||||||
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 sport_alias not in config.ODDS_SPORTS:
|
||||||
if message.from_user:
|
if message.from_user:
|
||||||
clear_user_sport_context(message.from_user.id)
|
clear_user_sport_context(message.from_user.id)
|
||||||
available = " / ".join(config.ODDS_SPORTS.keys())
|
await message.reply("Неизвестная лига. Доступно: dota2", parse_mode=None)
|
||||||
await message.reply(f"Неизвестная лига. Доступно: {available}", parse_mode=None)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if message.from_user:
|
if message.from_user:
|
||||||
if sport_alias in config.ODDS_SPORTS:
|
|
||||||
remember_user_sport_context(message.from_user.id, sport_alias)
|
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")
|
await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
|
||||||
result = await get_formatted_matches(sport_alias)
|
result = await get_formatted_matches(sport_alias)
|
||||||
await message.reply(result, parse_mode=None)
|
await message.reply(result, parse_mode=None)
|
||||||
|
|
@ -1504,41 +1484,34 @@ async def handle_bet_cmd(message: Message):
|
||||||
parts = raw_text.split()
|
parts = raw_text.split()
|
||||||
if len(parts) < 4:
|
if len(parts) < 4:
|
||||||
await message.reply(
|
await message.reply(
|
||||||
"Формат: /bet [спорт] [номер] [исход] [ставка]\n"
|
"Формат: /bet [номер] [исход] [ставка]\n"
|
||||||
"Коротко после /matches cs|dota|football: /bet [номер] [исход] [ставка]\n"
|
"Или: /bet dota2 [номер] [исход] [ставка]\n"
|
||||||
"Исход: 1/2, для ничьи X, можно ещё название команды или коэффициент.\n"
|
"Исход: 1/2. Можно писать ещё название команды или коэффициент.\n"
|
||||||
"Пример: /bet cs 1 1 0.5",
|
"Пример: /bet 1 1 0.5",
|
||||||
parse_mode=None,
|
parse_mode=None,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
sport_alias: str | None
|
sport_alias: str
|
||||||
if parts[1].lower() in config.ODDS_SPORTS:
|
if parts[1].lower() == "dota2":
|
||||||
if len(parts) < 5:
|
if len(parts) < 5:
|
||||||
await message.reply(
|
await message.reply(
|
||||||
"Формат: /bet [спорт] [номер] [исход] [ставка]\nПример: /bet cs 1 1 0.5",
|
"Формат: /bet dota2 [номер] [исход] [ставка]\nПример: /bet dota2 1 1 0.5",
|
||||||
parse_mode=None,
|
parse_mode=None,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
sport_alias = parts[1].lower()
|
sport_alias = "dota2"
|
||||||
index_token = parts[2]
|
index_token = parts[2]
|
||||||
selection = " ".join(parts[3:-1]).strip()
|
selection = " ".join(parts[3:-1]).strip()
|
||||||
amount_token = parts[-1]
|
amount_token = parts[-1]
|
||||||
else:
|
else:
|
||||||
sport_alias = get_user_sport_context(message.from_user.id)
|
sport_alias = "dota2"
|
||||||
if not sport_alias:
|
|
||||||
await message.reply(
|
|
||||||
"Сначала открой /matches с нужной лигой (epl, ucl, laliga...).\n"
|
|
||||||
"Либо пиши полный формат: /bet [спорт] [номер] [исход] [ставка]",
|
|
||||||
parse_mode=None,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
index_token = parts[1]
|
index_token = parts[1]
|
||||||
selection = " ".join(parts[2:-1]).strip()
|
selection = " ".join(parts[2:-1]).strip()
|
||||||
amount_token = parts[-1]
|
amount_token = parts[-1]
|
||||||
|
|
||||||
if not selection:
|
if not selection:
|
||||||
await message.reply("Нужен исход: 1, 2, X, название команды или коэффициент.", parse_mode=None)
|
await message.reply("Нужен исход: 1, 2, название команды или коэффициент.", parse_mode=None)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -174,13 +174,7 @@ async def get_profile(user: dict = Depends(get_current_user)) -> ProfileResponse
|
||||||
async def get_leagues() -> list[LeagueInfo]:
|
async def get_leagues() -> list[LeagueInfo]:
|
||||||
"""Список доступных лиг для ставок."""
|
"""Список доступных лиг для ставок."""
|
||||||
labels = {
|
labels = {
|
||||||
"epl": "⚽ EPL (Англия)",
|
"dota2": "🎮 Dota 2",
|
||||||
"ucl": "⚽ Champions League",
|
|
||||||
"laliga": "⚽ La Liga (Испания)",
|
|
||||||
"bundesliga": "⚽ Bundesliga (Германия)",
|
|
||||||
"seriea": "⚽ Serie A (Италия)",
|
|
||||||
"ligue1": "⚽ Ligue 1 (Франция)",
|
|
||||||
"europa": "⚽ Europa League",
|
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
LeagueInfo(alias=alias, name=labels.get(alias, alias), sport_key=key)
|
LeagueInfo(alias=alias, name=labels.get(alias, alias), sport_key=key)
|
||||||
|
|
|
||||||
|
|
@ -295,13 +295,17 @@ async function showBetHistory() {
|
||||||
|
|
||||||
async function renderBetting(data) {
|
async function renderBetting(data) {
|
||||||
if (data?.league) {
|
if (data?.league) {
|
||||||
return renderMatches(data.league, data.leagueName);
|
return renderMatches(data.league, data.leagueName, data.isOnlyLeague);
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = `<div class="section-header">⚽ Ставки на матчи</div>${skeleton(2)}`;
|
container.innerHTML = `<div class="section-header">⚽ Ставки на матчи</div>${skeleton(2)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const leagues = await API.getLeagues();
|
const leagues = await API.getLeagues();
|
||||||
|
if (leagues.length === 1) {
|
||||||
|
return renderMatches(leagues[0].alias, leagues[0].name, true);
|
||||||
|
}
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="section-header">⚽ Выбери лигу</div>
|
<div class="section-header">⚽ Выбери лигу</div>
|
||||||
<div class="league-list" id="league-list"></div>
|
<div class="league-list" id="league-list"></div>
|
||||||
|
|
@ -317,7 +321,7 @@ async function renderBetting(data) {
|
||||||
`);
|
`);
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
haptic();
|
haptic();
|
||||||
navigate('betting', { league: l.alias, leagueName: l.name });
|
navigate('betting', { league: l.alias, leagueName: l.name, isOnlyLeague: false });
|
||||||
});
|
});
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
});
|
});
|
||||||
|
|
@ -326,15 +330,22 @@ async function renderBetting(data) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderMatches(league, leagueName) {
|
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>`
|
||||||
|
: '';
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
|
<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>
|
<button class="btn btn-secondary btn-sm" id="back-btn" style="width:auto;padding:8px 12px">← Назад</button>
|
||||||
<div class="section-header" style="margin:0">${leagueName}</div>
|
<div class="section-header" style="margin:0">${headerText}</div>
|
||||||
</div>
|
</div>
|
||||||
|
${instructionsHtml}
|
||||||
${skeleton(3)}
|
${skeleton(3)}
|
||||||
`;
|
`;
|
||||||
document.getElementById('back-btn').onclick = () => navigate('betting');
|
document.getElementById('back-btn').onclick = () => navigate(backTarget);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const matches = await API.getMatches(league);
|
const matches = await API.getMatches(league);
|
||||||
|
|
@ -361,10 +372,12 @@ async function renderMatches(league, leagueName) {
|
||||||
<span class="odds-label">П1</span>
|
<span class="odds-label">П1</span>
|
||||||
<span class="odds-value">${m.odds_home?.toFixed(2) || '—'}</span>
|
<span class="odds-value">${m.odds_home?.toFixed(2) || '—'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
${m.odds_draw ? `
|
||||||
<button class="odds-btn" data-outcome="X" data-odds="${m.odds_draw || 0}">
|
<button class="odds-btn" data-outcome="X" data-odds="${m.odds_draw || 0}">
|
||||||
<span class="odds-label">X</span>
|
<span class="odds-label">X</span>
|
||||||
<span class="odds-value">${m.odds_draw?.toFixed(2) || '—'}</span>
|
<span class="odds-value">${m.odds_draw?.toFixed(2) || '—'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
` : ''}
|
||||||
<button class="odds-btn" data-outcome="2" data-odds="${m.odds_away || 0}">
|
<button class="odds-btn" data-outcome="2" data-odds="${m.odds_away || 0}">
|
||||||
<span class="odds-label">П2</span>
|
<span class="odds-label">П2</span>
|
||||||
<span class="odds-value">${m.odds_away?.toFixed(2) || '—'}</span>
|
<span class="odds-value">${m.odds_away?.toFixed(2) || '—'}</span>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue