forked from zovos/bot_tg
Merge pull request 'Ставки на спорт' (#13) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#13
This commit is contained in:
commit
7289296169
5 changed files with 405 additions and 1 deletions
1
.env
1
.env
|
|
@ -1,2 +1,3 @@
|
|||
BOT_TOKEN=8488665890:AAH7FY5I2xzHhfOdxlzb11ewl2I-MMTH2-o
|
||||
TZ=Europe/Moscow
|
||||
ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
15
config.py
15
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"
|
||||
|
|
|
|||
308
games/betting.py
Normal file
308
games/betting.py
Normal file
|
|
@ -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
|
||||
73
main.py
73
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()))
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue