From aee53349b13aa74afac12ef77b6287a35d6135ce Mon Sep 17 00:00:00 2001 From: Danil Date: Thu, 19 Mar 2026 23:49:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=81=D1=82=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=B0=D1=82=D1=87=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 1 + README.md | 9 +- config.py | 15 +++ games/betting.py | 308 +++++++++++++++++++++++++++++++++++++++++++++++ main.py | 73 +++++++++++ 5 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 games/betting.py diff --git a/.env b/.env index 3901b31..d863659 100644 --- a/.env +++ b/.env @@ -1,2 +1,3 @@ BOT_TOKEN=8488665890:AAH7FY5I2xzHhfOdxlzb11ewl2I-MMTH2-o TZ=Europe/Moscow +ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923 diff --git a/README.md b/README.md index 7d0585c..fe95fe2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ - авто-ответы в чате (10% шанс + триггер-слова); - миниигра `/penis` + `/top_penis`; - казино на размер (`/penis_casino`); -- гадание на фене с мемом (`/gadanie`). +- гадание на фене с мемом (`/gadanie`); +- ставки на матчи CS2, Dota 2, футбол с реальными коэффициентами (`/matches`, `/bet`). ## Запуск @@ -31,11 +32,13 @@ docker compose down Переменные окружения: задайте `BOT_TOKEN` в `.env`. Для ИИ-фич нужен `LLAMA_API_URL`. +Для ставок нужен `ODDS_API_KEY` (бесплатно на https://the-odds-api.com). Контейнер хранит состояние в `./db`: - `penis_stats.sqlite3` - `chat_history.sqlite3` - `polychaetsi_stats.json` +- `bets.sqlite3` ## Команды @@ -52,6 +55,9 @@ docker compose down - `/penis_casino <ставка>` — казино на размер (макс 1 см, казино всегда в выигрыше). - `/gadanie <тема>` — ИИ-гадание на фене + авто-мем. - `/ebalnik` — вкл/выкл авто-ответы бота в чате. +- `/matches [cs|dota|football]` — ближайшие матчи с коэффициентами. +- `/bet <спорт> <номер> <команда> <ставка>` — поставить см на матч. +- `/mybets` — мои активные ставки. - `/svodka` — сводка СВО. ## Макеты и шрифт @@ -80,6 +86,7 @@ docker compose down - `/talk` шлёт запрос к llama.cpp серверу с контекстом 100 сообщений (хранятся в SQLite). - `/penis_casino` — слоты с множителем x1.2–x3 (`games/casino.py`). - `/gadanie` — ИИ-гадание + авто-мем через `generate_meme` (`games/fortune.py`). +- `/matches` + `/bet` — ставки на реальные матчи, коэффициенты через The-Odds-API (`games/betting.py`). ## ИИ-модуль (`AI/talk_handler.py`) diff --git a/config.py b/config.py index f5c14b5..c8c68c1 100644 --- a/config.py +++ b/config.py @@ -27,6 +27,18 @@ POLYCHAETSI_IMAGE_PATH = BASE_DIR / "maket" / "cvetok.jpg" # --- Казино --- CASINO_MAX_BET = 1.0 +# --- Ставки на матчи --- +ODDS_API_KEY = os.getenv("ODDS_API_KEY", "") +ODDS_API_BASE = "https://api.the-odds-api.com/v4" +ODDS_SPORTS = { + "cs": "esports_csgo", + "dota": "esports_dota2", + "football": "soccer_epl", +} +BET_MAX_AMOUNT = 3.0 +BET_DB_PATH = "/db/bets.sqlite3" +ODDS_CACHE_TTL = 900 + # --- Magnit API --- MAGNIT_API_BASE_URL = os.getenv("MAGNIT_API_BASE_URL", "https://mirror.porno4free.ru/magnit") MAGNIT_STORE_CODE = os.getenv("MAGNIT_STORE_CODE", "618224") @@ -193,6 +205,9 @@ def get_hint_text(templates_str: str) -> str: "• /talk [текст] — ИИ базарит на фене.\n" "• /penis_casino [ставка] — казино на размер.\n" "• /gadanie [тема] — гадание на фене с мемом.\n" + "• /matches [cs|dota|football] — матчи для ставок.\n" + "• /bet <спорт> <номер> <команда> <ставка> — поставить см.\n" + "• /mybets — мои ставки.\n" "• /ebalnik — включить/выключить автоответы в чате.\n" "• /polychaetsi — топ по слову «получается».\n" "• /prices /fetch — цены и остатки Магнита.\n" diff --git a/games/betting.py b/games/betting.py new file mode 100644 index 0000000..c5da421 --- /dev/null +++ b/games/betting.py @@ -0,0 +1,308 @@ +import time as _time +import sqlite3 +import logging +from datetime import datetime +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]] = {} + + +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', + created_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.commit() + + +_init_bets_db() + + +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: + 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: + logger.warning(f"Odds API returned {resp.status}") + return cached[1] if cached else [] + data = await resp.json() + except Exception: + logger.exception("Failed to fetch odds") + 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["key"] == "h2h": + for outcome in market["outcomes"]: + name = outcome["name"] + price = outcome["price"] + if 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"], + "commence": event.get("commence_time", ""), + "odds": best_odds, + }) + + _matches_cache[sport_key] = (now, matches) + return matches + + +def format_matches(matches: list[dict], sport_label: str) -> str: + if not matches: + return f"🏟 {sport_label} — матчей не найдено." + + lines = [f"🏟 {sport_label} — ближайшие матчи:\n"] + for i, m in enumerate(matches[:10], 1): + odds_parts = [] + for team, coeff in m["odds"].items(): + odds_parts.append(f"{team}: {coeff:.2f}") + odds_str = " | ".join(odds_parts) + + 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']}\n {odds_str}{start_str}\n") + + lines.append("Ставка: /bet <номер> <команда> <сумма>") + 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 sport_alias and sport_alias in config.ODDS_SPORTS: + sport_key = config.ODDS_SPORTS[sport_alias] + matches = await fetch_matches(sport_key) + return format_matches(matches, _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, _get_sport_label(alias))) + return "\n\n".join(parts) + + +_last_fetched_matches: dict[str, list[dict]] = {} + + +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: + available = ", ".join(match["odds"].keys()) + return f"Команда не найдена. Доступные: {available}" + + 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, created_ts) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)""", + (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) + lines.append( + f"• {r['home_team']} vs {r['away_team']}\n" + f" {r['chosen_team']} (x{r['odds']:.2f}), {r['amount']:.1f} см → {potential:.1f} см" + ) + return "\n".join(lines) + + +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 + + 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: + continue + scores_data = await resp.json() + except Exception: + 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 + for s in scores: + score_val = int(s.get("score", 0)) + if score_val > max_score: + max_score = score_val + winner = s["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: + if bet["chosen_team"] == winner: + 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"],)) + 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"],)) + 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") + + return notifications diff --git a/main.py b/main.py index 0d1bf6a..a30a13a 100644 --- a/main.py +++ b/main.py @@ -22,6 +22,7 @@ from aiogram.client.default import DefaultBotProperties from AI.talk_handler import handle_talk, generate_autoreply, push_message from games.casino import play_casino from games.fortune import generate_fortune +from games.betting import get_formatted_matches, get_match_by_index, place_bet, get_user_bets, settle_bets from zparser import get_military_data import config @@ -1215,6 +1216,67 @@ async def handle_gadanie_cmd(message: Message): logger.exception("Gadanie failed") await message.reply("Бля, карты рассыпались, попробуй позже.") +async def handle_matches_cmd(message: Message): + parts = message.text.split(maxsplit=1) + sport_alias = parts[1].strip().lower() if len(parts) > 1 else None + await message.bot.send_chat_action(chat_id=message.chat.id, action="typing") + result = await get_formatted_matches(sport_alias) + await message.reply(result, parse_mode=None) + +async def handle_bet_cmd(message: Message): + if not message.from_user: + await message.answer("Команда доступна только пользователям.") + return + parts = message.text.split() + if len(parts) < 4: + await message.reply( + "Формат: /bet <номер> <команда> <ставка>\n" + "Пример: /bet cs 1 NAVI 0.5\n" + "Спорт: cs / dota / football" + ) + return + # /bet <спорт> <номер> <команда> <ставка> + if len(parts) >= 5: + sport_alias = parts[1].lower() + try: + index = int(parts[2]) + except ValueError: + await message.reply("Номер матча должен быть числом.") + return + team = parts[3] + try: + amount = round(float(parts[4].replace(",", ".")), 1) + except ValueError: + await message.reply("Ставка должна быть числом.") + return + else: + await message.reply("Формат: /bet <спорт> <номер> <команда> <ставка>") + return + + match = await get_match_by_index(sport_alias, index) + if not match: + await message.reply("Матч не найден. Посмотри /matches") + return + + result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount) + await message.reply(result, parse_mode=None) + +async def handle_mybets_cmd(message: Message): + if not message.from_user: + return + result = await asyncio.to_thread(get_user_bets, message.from_user.id) + await message.reply(result, parse_mode=None) + +async def _settle_loop(bot: Bot): + while True: + await asyncio.sleep(1800) + try: + notifications = await settle_bets() + for note in notifications: + logger.info(note) + except Exception: + logger.exception("settle_bets failed") + # --- Запуск --- def main(): @@ -1244,6 +1306,9 @@ def main(): dp.message.register(handle_ebalnik_cmd, Command("ebalnik")) dp.message.register(handle_penis_casino_cmd, Command("penis_casino")) dp.message.register(handle_gadanie_cmd, Command("gadanie")) + 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_gen_mem, Command("gen_mem")) dp.message.register(handle_keywords, F.text) @@ -1267,6 +1332,9 @@ def main(): BotCommand(command="ebalnik", description="вкл/выкл автответов"), BotCommand(command="penis_casino", description="казино на размер"), BotCommand(command="gadanie", description="гадание на фене"), + BotCommand(command="matches", description="матчи для ставок"), + BotCommand(command="bet", description="поставить см на матч"), + BotCommand(command="mybets", description="мои ставки"), BotCommand(command="svodka", description="СВО: итоги"), ] scopes = ( @@ -1280,6 +1348,11 @@ def main(): logger.warning("Не удалось выставить команды, но бот продолжит работу.") dp.startup.register(on_startup) + + async def start_settle(bot: Bot): + asyncio.create_task(_settle_loop(bot)) + dp.startup.register(start_settle) + logger.info("Бот запущен...") asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())) -- 2.45.2