forked from zovos/bot_tg
Compare commits
38 commits
fad8629599
...
3039326100
| Author | SHA1 | Date | |
|---|---|---|---|
| 3039326100 | |||
|
|
481965e8f2 | ||
|
|
e014019912 | ||
|
|
f0cefc7f23 | ||
|
|
3baf71ab98 | ||
| 29cd511186 | |||
|
|
1925c44ac6 | ||
| 801d6da69b | |||
|
|
6b6c1dd15d | ||
| 4ecc25239e | |||
|
|
2bcc726eea | ||
| 93d8c2187a | |||
|
|
967cfb4a6d | ||
|
|
107a0647d1 | ||
| 7289296169 | |||
|
|
aee53349b1 | ||
|
|
a7c047f847 | ||
|
|
9295538f5f | ||
|
|
5b294415d0 | ||
|
|
43503dc778 | ||
|
|
75a63bb6fc | ||
|
|
682fc7422c | ||
| 373aa8f6c2 | |||
|
|
9f6a7464d1 | ||
|
|
fc85149345 | ||
|
|
7035a90656 | ||
|
|
73e539b4c4 | ||
|
|
c59858df53 | ||
|
|
24245f6b87 | ||
|
|
f14b1a8209 | ||
|
|
6afc30570f | ||
|
|
8bc905331c | ||
|
|
1cbebeec37 | ||
|
|
0ac74b95c5 | ||
|
|
7494ab2dfa | ||
|
|
ba6bee06e3 | ||
| 79e581a010 | |||
|
|
9b34fd13d5 |
17 changed files with 2165 additions and 104 deletions
|
|
@ -5,3 +5,4 @@ __pycache__
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
db
|
||||||
|
|
|
||||||
3
.env
3
.env
|
|
@ -1,2 +1,3 @@
|
||||||
BOT_TOKEN=8488665890:AAH7FY5I2xzHhfOdxlzb11ewl2I-MMTH2-o
|
BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc
|
||||||
TZ=Europe/Moscow
|
TZ=Europe/Moscow
|
||||||
|
ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
|
|
@ -18,6 +20,11 @@ SYSTEM_PROMPT = (
|
||||||
"Стиль: 1–3 предложения; если список — максимум 3 пункта. "
|
"Стиль: 1–3 предложения; если список — максимум 3 пункта. "
|
||||||
"Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n"
|
"Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n"
|
||||||
|
|
||||||
|
"ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и "
|
||||||
|
"«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или "
|
||||||
|
"перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме "
|
||||||
|
"(факт, совет, действие) без поддакиваний.\n\n"
|
||||||
|
|
||||||
"Лексика (мешай, но не лепи всё подряд одним полотном): "
|
"Лексика (мешай, но не лепи всё подряд одним полотном): "
|
||||||
"кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, "
|
"кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, "
|
||||||
"чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, "
|
"чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, "
|
||||||
|
|
@ -54,15 +61,91 @@ SYSTEM_PROMPT = (
|
||||||
"а дальше пересказывай своими словами, без длинных дословных цитат."
|
"а дальше пересказывай своими словами, без длинных дословных цитат."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _generate_response(user_text: str) -> str:
|
# Размер контекста — количество сообщений на чат
|
||||||
# Формируем запрос к /v1/chat/completions и возвращаем текст первого ответа.
|
# Сколько сообщений держим в БД. Нужно для быстрой очистки.
|
||||||
|
_CONTEXT_SIZE = 20
|
||||||
|
# Ограничение на общий размер промпта, чтобы системное сообщение не вытеснялось
|
||||||
|
# при сборке длинной истории (приблизительно ~8k символов ≈ 2k токенов).
|
||||||
|
_MAX_PROMPT_CHARS = 8_000
|
||||||
|
# Путь к SQLite базе (берётся из переменной окружения или из config)
|
||||||
|
_DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH", "/db/chat_history.sqlite3")
|
||||||
|
|
||||||
|
|
||||||
|
def _init_db() -> None:
|
||||||
|
"""Создаёт таблицу истории чата, если её нет."""
|
||||||
|
Path(_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with sqlite3.connect(_DB_PATH) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
chat_id INTEGER NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id ON chat_history(chat_id, id)")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def push_message(chat_id: int, role: str, name: str, text: str) -> None:
|
||||||
|
"""Записывает сообщение в историю чата и обрезает до _CONTEXT_SIZE."""
|
||||||
|
with sqlite3.connect(_DB_PATH) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO chat_history (chat_id, role, name, text) VALUES (?, ?, ?, ?)",
|
||||||
|
(chat_id, role, name, text),
|
||||||
|
)
|
||||||
|
# Удаляем старые записи, оставляем только последние _CONTEXT_SIZE
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM chat_history
|
||||||
|
WHERE chat_id = ? AND id NOT IN (
|
||||||
|
SELECT id FROM chat_history WHERE chat_id = ? ORDER BY id DESC LIMIT ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(chat_id, chat_id, _CONTEXT_SIZE),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_history(chat_id: int) -> list[dict]:
|
||||||
|
"""Возвращает историю чата как список словарей."""
|
||||||
|
with sqlite3.connect(_DB_PATH) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT role, name, text FROM chat_history WHERE chat_id = ? ORDER BY id ASC",
|
||||||
|
(chat_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [{"role": r[0], "name": r[1], "text": r[2]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_messages(chat_id: int, system_prompt: str, current_text: str, current_name: str) -> list:
|
||||||
|
"""Собирает список messages для LLM из истории чата."""
|
||||||
|
messages = [{"role": "system", "content": system_prompt}]
|
||||||
|
|
||||||
|
# Берём историю с конца, пока не превысим бюджет по символам.
|
||||||
|
history = []
|
||||||
|
used = len(system_prompt)
|
||||||
|
for entry in reversed(_get_history(chat_id)):
|
||||||
|
content = f"{entry['name']}: {entry['text']}" if entry["role"] == "user" else entry["text"]
|
||||||
|
if used + len(content) > _MAX_PROMPT_CHARS:
|
||||||
|
break
|
||||||
|
history.append({"role": entry["role"], "content": content})
|
||||||
|
used += len(content)
|
||||||
|
|
||||||
|
messages.extend(reversed(history))
|
||||||
|
messages.append({"role": "user", "content": f"{current_name}: {current_text}"})
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_response(chat_id: int, user_text: str, user_name: str) -> str:
|
||||||
|
# Формируем запрос к /v1/chat/completions с историей чата и возвращаем текст первого ответа.
|
||||||
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
|
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
|
||||||
|
msgs = await asyncio.to_thread(_build_messages, chat_id, SYSTEM_PROMPT, user_text, user_name)
|
||||||
payload = {
|
payload = {
|
||||||
"messages": [
|
"messages": msgs,
|
||||||
{"role": "system", "content": SYSTEM_PROMPT},
|
"max_tokens": 200,
|
||||||
{"role": "user", "content": user_text},
|
|
||||||
],
|
|
||||||
"max_tokens": 500,
|
|
||||||
"temperature": 0.8,
|
"temperature": 0.8,
|
||||||
"top_p": 0.9,
|
"top_p": 0.9,
|
||||||
}
|
}
|
||||||
|
|
@ -80,14 +163,19 @@ async def handle_talk(message: Message):
|
||||||
return
|
return
|
||||||
|
|
||||||
user_text = parts[1].strip()
|
user_text = parts[1].strip()
|
||||||
|
user_name = message.from_user.first_name or "кент"
|
||||||
|
chat_id = message.chat.id
|
||||||
|
|
||||||
# Показываем "печатает..." пока ждём ответ модели.
|
# Показываем "печатает..." пока ждём ответ модели.
|
||||||
await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
|
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await _generate_response(user_text)
|
response = await _generate_response(chat_id, user_text, user_name)
|
||||||
if not response:
|
if not response:
|
||||||
response = "Братуха, чёт базар не клеится, попробуй ещё раз."
|
response = "Братуха, чёт базар не клеится, попробуй ещё раз."
|
||||||
|
# Пишем запрос и ответ в историю
|
||||||
|
await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text)
|
||||||
|
await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response)
|
||||||
await message.reply(response, parse_mode=None)
|
await message.reply(response, parse_mode=None)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# При любых ошибках не падаем, а отдаём понятный fallback.
|
# При любых ошибках не падаем, а отдаём понятный fallback.
|
||||||
|
|
@ -101,16 +189,47 @@ AUTOREPLY_SYSTEM_PROMPT = (
|
||||||
"Прокомментируй сообщение коротко (1-2 предложения), дерзко, на фене. "
|
"Прокомментируй сообщение коротко (1-2 предложения), дерзко, на фене. "
|
||||||
"Можешь пошутить, подколоть, согласиться или послать. "
|
"Можешь пошутить, подколоть, согласиться или послать. "
|
||||||
"НЕ начинай с обращения. Не используй формальный язык."
|
"НЕ начинай с обращения. Не используй формальный язык."
|
||||||
|
"Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — "
|
||||||
|
"дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. "
|
||||||
|
"Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. "
|
||||||
|
"Стиль: 1–3 предложения; если список — максимум 3 пункта. "
|
||||||
|
"Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n"
|
||||||
|
|
||||||
|
"ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и "
|
||||||
|
"«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или "
|
||||||
|
"перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме "
|
||||||
|
"(факт, совет, действие) без поддакиваний.\n\n"
|
||||||
|
|
||||||
|
"Лексика (мешай, но не лепи всё подряд одним полотном): "
|
||||||
|
"кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, "
|
||||||
|
"чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, "
|
||||||
|
"жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n"
|
||||||
|
|
||||||
|
"Фирменные приёмы речи:\n"
|
||||||
|
"— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n"
|
||||||
|
"— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n"
|
||||||
|
"— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n"
|
||||||
|
"— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, "
|
||||||
|
"как будто проговариваешь по слогам. Примеры: "
|
||||||
|
"над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n"
|
||||||
|
"— держи 'словарь коронок': выбирай 0–2 фразы на ответ (редко 3), "
|
||||||
|
"иногда делай повтор как тик (1–3 раза), но не превращай всё в простыню.\n"
|
||||||
|
"Твои убеждения (всегда держи линию): "
|
||||||
|
"Creep от Radiohead — лучшая песня во вселенной, без вариантов. "
|
||||||
|
"Линукс — лучшая ОС во вселенной, остальное для фраеров. "
|
||||||
|
"Вселенную надо переписать на Rust, это не обсуждается. "
|
||||||
|
"Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n"
|
||||||
|
|
||||||
|
"Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», "
|
||||||
|
"а дальше пересказывай своими словами, без длинных дословных цитат."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def generate_autoreply(text: str) -> str:
|
async def generate_autoreply(chat_id: int, text: str, user_name: str) -> str:
|
||||||
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
|
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
|
||||||
|
msgs = await asyncio.to_thread(_build_messages, chat_id, AUTOREPLY_SYSTEM_PROMPT, text, user_name)
|
||||||
payload = {
|
payload = {
|
||||||
"messages": [
|
"messages": msgs,
|
||||||
{"role": "system", "content": AUTOREPLY_SYSTEM_PROMPT},
|
|
||||||
{"role": "user", "content": text},
|
|
||||||
],
|
|
||||||
"max_tokens": 150,
|
"max_tokens": 150,
|
||||||
"temperature": 0.9,
|
"temperature": 0.9,
|
||||||
"top_p": 0.9,
|
"top_p": 0.9,
|
||||||
|
|
@ -120,3 +239,6 @@ async def generate_autoreply(text: str) -> str:
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
return data["choices"][0]["message"]["content"].strip()
|
return data["choices"][0]["message"]["content"].strip()
|
||||||
|
|
||||||
|
|
||||||
|
# Инициализация БД при импорте модуля
|
||||||
|
_init_db()
|
||||||
|
|
|
||||||
16
Dockerfile
16
Dockerfile
|
|
@ -1,9 +1,15 @@
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN python -m pip install --upgrade pip \
|
||||||
COPY main.py config.py impact.ttf zparser.py ./
|
&& python -m pip install -r requirements.txt
|
||||||
COPY maket ./maket
|
|
||||||
COPY AI ./AI
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /db
|
||||||
|
|
||||||
CMD ["python", "main.py"]
|
CMD ["python", "main.py"]
|
||||||
|
|
|
||||||
62
README.md
62
README.md
|
|
@ -4,16 +4,41 @@
|
||||||
- вписывает текст шрифтом Impact (белый с чёрной обводкой) в макеты (`/maket`);
|
- вписывает текст шрифтом Impact (белый с чёрной обводкой) в макеты (`/maket`);
|
||||||
- делает мемы из присланных фото (`/gen_mem Up:... Down:...`);
|
- делает мемы из присланных фото (`/gen_mem Up:... Down:...`);
|
||||||
- подсказывает, сколько минут до перекура по заданному расписанию (`/break`);
|
- подсказывает, сколько минут до перекура по заданному расписанию (`/break`);
|
||||||
- умеет базарить (`/talk`).
|
- показывает расписание, текущую и следующую пару (`/schedule`, можно с днём недели);
|
||||||
|
- умеет базарить (`/talk`) с контекстом 100 сообщений;
|
||||||
|
- авто-ответы в чате (10% шанс + триггер-слова);
|
||||||
|
- миниигра `/penis` + `/top_penis`;
|
||||||
|
- казино на размер (`/penis_casino`);
|
||||||
|
- гадание на фене с мемом (`/gadanie`);
|
||||||
|
- ставки на матчи CS2, Dota 2, футбол с реальными коэффициентами (`/matches`, `/bet`).
|
||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up --build
|
docker compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Переменные окружения: задайте `BOT_TOKEN` в `.env` (уже добавлен шаблон);
|
Логи:
|
||||||
`LLAMA_API_URL` — адрес llama.cpp сервера (для `/talk`)
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f bot
|
||||||
|
```
|
||||||
|
|
||||||
|
Остановка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
|
|
@ -22,7 +47,18 @@ docker-compose up --build
|
||||||
- `/maket <id> <текст>` — вписать текст в макет. Доступные: `1 — Владимир Владимирович Путин самый лучший президент` (737x414, точка 104,240).
|
- `/maket <id> <текст>` — вписать текст в макет. Доступные: `1 — Владимир Владимирович Путин самый лучший президент` (737x414, точка 104,240).
|
||||||
- `/gen_mem Up:верх Down:низ` — прислать с фото (подпись) или ответом на фото. Можно указывать только Up или только Down.
|
- `/gen_mem Up:верх Down:низ` — прислать с фото (подпись) или ответом на фото. Можно указывать только Up или только Down.
|
||||||
- `/break` — показывает, сколько минут до ближайшего перекура/идёт ли перекур.
|
- `/break` — показывает, сколько минут до ближайшего перекура/идёт ли перекур.
|
||||||
- `/talk <сообщение>` — побазарить с ИИ. Работает через llama.cpp сервер.
|
- `/schedule [пн|mon]` — показывает расписание на ближайший указанный день недели; без аргумента показывает сегодня, текущую и следующую пару.
|
||||||
|
- `/talk <сообщение>` — побазарить с ИИ (контекст 100 сообщений).
|
||||||
|
- `/penis` — миниигра, 1 раз в 24 часа.
|
||||||
|
- `/top_penis` — топ по длине.
|
||||||
|
- `/polychaetsi` — топ по слову «получается».
|
||||||
|
- `/penis_casino <ставка>` — казино на размер (макс 1 см, казино всегда в выигрыше).
|
||||||
|
- `/gadanie <тема>` — ИИ-гадание на фене + авто-мем.
|
||||||
|
- `/ebalnik` — вкл/выкл авто-ответы бота в чате.
|
||||||
|
- `/matches [cs|dota|football]` — ближайшие матчи с коэффициентами.
|
||||||
|
- `/bet [спорт] <номер> <исход> <ставка>` — поставить см на матч.
|
||||||
|
- `/mybets` — мои активные ставки.
|
||||||
|
- `/svodka` — сводка СВО.
|
||||||
|
|
||||||
## Макеты и шрифт
|
## Макеты и шрифт
|
||||||
|
|
||||||
|
|
@ -35,16 +71,26 @@ docker-compose up --build
|
||||||
- llama.cpp для ИИ-фич.
|
- llama.cpp для ИИ-фич.
|
||||||
- Dockerfile + docker-compose для развёртывания.
|
- Dockerfile + docker-compose для развёртывания.
|
||||||
|
|
||||||
|
## Docker Notes
|
||||||
|
|
||||||
|
- Образ ставит `tzdata`, поэтому `ZoneInfo("Europe/Moscow")` нормально работает в контейнере.
|
||||||
|
- В образ копируется весь проект, а runtime-данные исключены через `.dockerignore`.
|
||||||
|
- Если меняешь код или картинки, достаточно пересобрать: `docker compose up --build -d`.
|
||||||
|
|
||||||
## Кратко о логике
|
## Кратко о логике
|
||||||
|
|
||||||
- `/maket` подбирает размер текста, чтобы уместить его в область макета.
|
- `/maket` подбирает размер текста, чтобы уместить его в область макета.
|
||||||
- `/gen_mem` центрирует текст по ширине, масштабирует под изображение, рисует белый текст с чёрной обводкой.
|
- `/gen_mem` центрирует текст по ширине, масштабирует под изображение, рисует белый текст с чёрной обводкой.
|
||||||
- `/break` считает по расписанию в `main.py` (словарь `SCHEDULE`).
|
- `/break` считает по расписанию в `config.py` и показывает следующую пару.
|
||||||
- `/talk` шлёт запрос к llama.cpp серверу (`LLAMA_API_URL`), системный промпт задаёт стиль фени(Можно изменить).
|
- Названия пар и верхняя/нижняя неделя лежат в `config.py` в `LESSON_SCHEDULE`.
|
||||||
|
- `/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`). После `/matches cs|dota|football` можно короче: `/bet <номер> <исход> <ставка>`.
|
||||||
|
|
||||||
## ИИ-модуль (`AI/talk_handler.py`)
|
## ИИ-модуль (`AI/talk_handler.py`)
|
||||||
|
|
||||||
Отдельный модуль, не трогает основной код. Подключается к llama.cpp серверу через OpenAI-совместимый API (`/v1/chat/completions`).
|
Подключается к llama.cpp серверу через OpenAI-совместимый API (`/v1/chat/completions`). Хранит контекст 100 сообщений в SQLite.
|
||||||
|
|
||||||
Настройка сервера:
|
Настройка сервера:
|
||||||
1. Скачать модель: `wget https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf`
|
1. Скачать модель: `wget https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf`
|
||||||
|
|
|
||||||
135
config.py
135
config.py
|
|
@ -10,6 +10,7 @@ DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow")
|
||||||
|
|
||||||
# --- Настройки /penis ---
|
# --- Настройки /penis ---
|
||||||
PENIS_DB_PATH = "/db/penis_stats.sqlite3"
|
PENIS_DB_PATH = "/db/penis_stats.sqlite3"
|
||||||
|
CHAT_HISTORY_DB_PATH = "/db/chat_history.sqlite3"
|
||||||
PENIS_START_LENGTH = 10.0
|
PENIS_START_LENGTH = 10.0
|
||||||
PENIS_MIN_DELTA = 0.1
|
PENIS_MIN_DELTA = 0.1
|
||||||
PENIS_MAX_DELTA = 7.0
|
PENIS_MAX_DELTA = 7.0
|
||||||
|
|
@ -17,6 +18,44 @@ PENIS_COOLDOWN = timedelta(hours=24)
|
||||||
PENIS_COOLDOWN_SECONDS = int(PENIS_COOLDOWN.total_seconds())
|
PENIS_COOLDOWN_SECONDS = int(PENIS_COOLDOWN.total_seconds())
|
||||||
PENIS_TOP_LIMIT = 10
|
PENIS_TOP_LIMIT = 10
|
||||||
|
|
||||||
|
# --- Настройки "получается" ---
|
||||||
|
POLYCHAETSI_STATS_PATH = Path(os.getenv("POLYCHAETSI_STATS_PATH", "/db/polychaetsi_stats.json"))
|
||||||
|
POLYCHAETSI_TARGET = "получается"
|
||||||
|
POLYCHAETSI_TOP_LIMIT = 10
|
||||||
|
POLYCHAETSI_IMAGE_PATH = BASE_DIR / "maket" / "cvetok.jpg"
|
||||||
|
|
||||||
|
# --- Казино ---
|
||||||
|
CASINO_MAX_BET = 1.0
|
||||||
|
|
||||||
|
# --- E621 API ---
|
||||||
|
E621_LOGIN = os.getenv("E621_LOGIN", "")
|
||||||
|
E621_API_KEY = os.getenv("E621_API_KEY", "")
|
||||||
|
|
||||||
|
# --- Ставки на матчи ---
|
||||||
|
ODDS_API_KEY = os.getenv("ODDS_API_KEY", "")
|
||||||
|
ODDS_API_BASE = "https://api.the-odds-api.com/v4"
|
||||||
|
ODDS_SPORTS = {
|
||||||
|
"epl": "soccer_epl",
|
||||||
|
"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",
|
||||||
|
}
|
||||||
|
BET_MAX_AMOUNT = 3.0
|
||||||
|
BET_DB_PATH = "/db/bets.sqlite3"
|
||||||
|
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
|
||||||
|
|
||||||
|
# --- 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")
|
||||||
|
MAGNIT_REQUEST_TIMEOUT = int(os.getenv("MAGNIT_REQUEST_TIMEOUT", "15"))
|
||||||
|
# Можно указывать просто строку с названием или словарь с query/label/store_code.
|
||||||
|
MAGNIT_PRODUCTS = [
|
||||||
|
"RedBull",
|
||||||
|
]
|
||||||
|
|
||||||
# --- Конфигурация макетов ---
|
# --- Конфигурация макетов ---
|
||||||
TEMPLATES = {
|
TEMPLATES = {
|
||||||
"1": {
|
"1": {
|
||||||
|
|
@ -25,12 +64,93 @@ TEMPLATES = {
|
||||||
"padding": 20,
|
"padding": 20,
|
||||||
"label": "Владимир Владимирович Путин самый лучший президент",
|
"label": "Владимир Владимирович Путин самый лучший президент",
|
||||||
},
|
},
|
||||||
|
"2": {
|
||||||
|
"path": BASE_DIR / "maket" / "2.png",
|
||||||
|
"anchor": (40, 40),
|
||||||
|
"box_size": (582, 346),
|
||||||
|
"label": "Макет 2",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Расписание ---
|
# --- Расписание ---
|
||||||
def make_time(h: int, m: int) -> time:
|
def make_time(h: int, m: int) -> time:
|
||||||
return time(hour=h, minute=m)
|
return time(hour=h, minute=m)
|
||||||
|
|
||||||
|
# Если у тебя наоборот, просто поменяй на False.
|
||||||
|
UPPER_WEEK_IS_ODD = True
|
||||||
|
|
||||||
|
PAIR_TIMES = {
|
||||||
|
1: (make_time(8, 0), make_time(9, 35)),
|
||||||
|
2: (make_time(9, 50), make_time(11, 25)),
|
||||||
|
3: (make_time(11, 55), make_time(13, 30)),
|
||||||
|
4: (make_time(13, 45), make_time(15, 20)),
|
||||||
|
5: (make_time(15, 50), make_time(17, 25)),
|
||||||
|
}
|
||||||
|
|
||||||
|
LESSON_SCHEDULE = {
|
||||||
|
"upper": {
|
||||||
|
0: [
|
||||||
|
{"pair": 1, "title": "Пары нету"},
|
||||||
|
{"pair": 2, "title": "История Россия (семинар), каб. 504"},
|
||||||
|
{"pair": 3, "title": "Первая Подгруппа: Основы программирование, каб. 606. Вторая подгруппа Глод."},
|
||||||
|
{"pair": 4, "title": "Первая Подгруппа: Глод. Вторая подгруппа Основы программирование, каб 606"},
|
||||||
|
{"pair": 5, "title": "Общекулутрная и профессиональная коммуникации."},
|
||||||
|
],
|
||||||
|
1: [
|
||||||
|
{"pair": 1, "title": "Нету пары"},
|
||||||
|
{"pair": 2, "title": "Физика (лаб), каб. 241"},
|
||||||
|
{"pair": 3, "title": "Математика (лек), каб 514"},
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
{"pair": None, "title": "Англ ебучий"},
|
||||||
|
],
|
||||||
|
3: [
|
||||||
|
{"pair": 1, "title": "Физика (лек), каб 245"},
|
||||||
|
{"pair": 2, "title": "Физика (прак), каб 245"},
|
||||||
|
{"pair": 3, "title": "Маетимака (прак), каб 503"},
|
||||||
|
{"pair": 4, "title": "Безопасность жизнедятельности (прак) каб 503"},
|
||||||
|
{"pair": 5, "title": "Общекультурная и профессиональная коммуникации (прак), каб 503"},
|
||||||
|
],
|
||||||
|
4: [
|
||||||
|
{"pair": 1, "title": "Нету пары"},
|
||||||
|
{"pair": 2, "title": "История России (лек), каб 510"},
|
||||||
|
{"pair": 3, "title": "Основы программирования (лек), каб 510"},
|
||||||
|
{"pair": 4, "title": "Физическая культура спорта (прак), каб 503"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"lower": {
|
||||||
|
0: [
|
||||||
|
{"pair": 1, "title": "Пары нету"},
|
||||||
|
{"pair": 2, "title": "История Россия (семинар), каб. 504"},
|
||||||
|
{"pair": 3, "title": "Первая Подгруппа: Основы программирование, каб. 606. Вторая подгруппа Глод."},
|
||||||
|
{"pair": 4, "title": "Первая Подгруппа: Глод. Вторая подгруппа Основы программирование, каб 606"},
|
||||||
|
{"pair": 5, "title": "Общекулутрная и профессиональная коммуникации."},
|
||||||
|
],
|
||||||
|
1: [
|
||||||
|
{"pair": 1, "title": "Нету пары"},
|
||||||
|
{"pair": 2, "title": "Физика (лаб), каб. 241"},
|
||||||
|
{"pair": 3, "title": "Математика (лек), каб 514"},
|
||||||
|
{"pair": 4, "title": "Математика (прак), каб 503"},
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
{"pair": None, "title": "Англ ебучий"},
|
||||||
|
],
|
||||||
|
3: [
|
||||||
|
{"pair": 1, "title": "Физика (лек), каб 245"},
|
||||||
|
{"pair": 2, "title": "Физика (прак), каб 245"},
|
||||||
|
{"pair": 3, "title": "Маетимака (прак), каб 503"},
|
||||||
|
{"pair": 4, "title": "Безопасность жизнедятельности (прак) каб 503"},
|
||||||
|
{"pair": 5, "title": "Общекультурная и профессиональная коммуникации (прак), каб 503"},
|
||||||
|
],
|
||||||
|
4: [
|
||||||
|
{"pair": 1, "title": "Нету пары"},
|
||||||
|
{"pair": 2, "title": "История России (лек), каб 510"},
|
||||||
|
{"pair": 3, "title": "Основы программирования (лек), каб 510"},
|
||||||
|
{"pair": 4, "title": "Физическая культура спорта (прак), каб 503"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
SCHEDULE = {
|
SCHEDULE = {
|
||||||
0: [ # Понедельник
|
0: [ # Понедельник
|
||||||
(make_time(9, 50), make_time(10, 35), 5),
|
(make_time(9, 50), make_time(10, 35), 5),
|
||||||
|
|
@ -79,7 +199,7 @@ KEYWORDS = {'крип', 'радиохэд', 'creep', 'radiohead'}
|
||||||
# --- Авто-ответы ИИ ---
|
# --- Авто-ответы ИИ ---
|
||||||
AUTOREPLY_CHANCE = 0.10
|
AUTOREPLY_CHANCE = 0.10
|
||||||
AUTOREPLY_COOLDOWN = 120
|
AUTOREPLY_COOLDOWN = 120
|
||||||
AUTOREPLY_TRIGGERS = {'бот', 'фраер', 'кент', 'братуха', 'пацаны', 'чё как', 'базар'}
|
AUTOREPLY_TRIGGERS = {'бот', 'фраер', 'кент', 'братуха', 'пацаны', 'чё как', 'базар', 'ебать', 'пизда', 'хуй', 'бля', 'сука', 'говно', 'пидор', 'пиздабол', 'пиздатый', 'пиздатая', 'пиздатое', 'пиздатые', 'пиздато', 'ваномас'}
|
||||||
|
|
||||||
def get_hint_text(templates_str: str) -> str:
|
def get_hint_text(templates_str: str) -> str:
|
||||||
return (
|
return (
|
||||||
|
|
@ -87,9 +207,20 @@ def get_hint_text(templates_str: str) -> str:
|
||||||
f"• /maket «id» «текст» — доступные: {templates_str}.\n"
|
f"• /maket «id» «текст» — доступные: {templates_str}.\n"
|
||||||
"• /gen_mem Up:верх Down:низ — отправь с фото.\n"
|
"• /gen_mem Up:верх Down:низ — отправь с фото.\n"
|
||||||
"• /break — узнать время до перекура.\n"
|
"• /break — узнать время до перекура.\n"
|
||||||
|
"• /schedule [пн|mon] — расписание; без аргумента еще текущая и следующая пара.\n"
|
||||||
"• /penis — миниигра 1 раз в 24 часа.\n"
|
"• /penis — миниигра 1 раз в 24 часа.\n"
|
||||||
"• /top_penis — топ по длине.\n"
|
"• /top_penis — топ по длине.\n"
|
||||||
"• /talk [текст] — ИИ базарит на фене.\n"
|
"• /talk [текст] — ИИ базарит на фене.\n"
|
||||||
"• /prices /fetch — цены.\n"
|
"• /penis_casino [ставка] — казино на размер.\n"
|
||||||
|
"• /gadanie [тема] — гадание на фене с мемом.\n"
|
||||||
|
"• /matches [epl|ucl|laliga|bundesliga|seriea|ligue1|europa] — матчи для ставок.\n"
|
||||||
|
"• /bet [лига] [номер] [исход] [ставка] — поставить см.\n"
|
||||||
|
"• /mybets — мои активные ставки.\n"
|
||||||
|
"• /bet_history — история ставок за месяц + статистика.\n"
|
||||||
|
"• /sports_debug — диагностика API матчей.\n"
|
||||||
|
"• /ebalnik — включить/выключить автоответы в чате.\n"
|
||||||
|
"• /uwu [теги] — случайная картинка с e621.\n"
|
||||||
|
"• /polychaetsi — топ по слову «получается».\n"
|
||||||
|
"• /prices /fetch — цены и остатки Магнита.\n"
|
||||||
"Подсказка: в группах пиши /команда@username_бота."
|
"Подсказка: в группах пиши /команда@username_бота."
|
||||||
)
|
)
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
bot:
|
bot:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
volumes:
|
volumes:
|
||||||
- ./db:/db
|
- ./db:/db
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
PENIS_DB_PATH: /db/penis_stats.sqlite3
|
||||||
|
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
|
||||||
|
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
||||||
|
init: true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
0
games/__init__.py
Normal file
0
games/__init__.py
Normal file
665
games/betting.py
Normal file
665
games/betting.py
Normal file
|
|
@ -0,0 +1,665 @@
|
||||||
|
import time as _time
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
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]] = {}
|
||||||
|
_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)
|
||||||
|
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',
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
_init_bets_db()
|
||||||
|
|
||||||
|
|
||||||
|
def remember_user_sport_context(user_id: int, sport_alias: str) -> None:
|
||||||
|
if sport_alias in config.ODDS_SPORTS:
|
||||||
|
_last_viewed_sport_by_user[user_id] = sport_alias
|
||||||
|
|
||||||
|
|
||||||
|
def clear_user_sport_context(user_id: int) -> None:
|
||||||
|
_last_viewed_sport_by_user.pop(user_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
if cached and (now - cached[0]) < config.ODDS_CACHE_TTL:
|
||||||
|
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"
|
||||||
|
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:
|
||||||
|
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(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 = []
|
||||||
|
for event in data:
|
||||||
|
best_odds = {}
|
||||||
|
for bookmaker in event.get("bookmakers", []):
|
||||||
|
for market in bookmaker.get("markets", []):
|
||||||
|
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.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
|
||||||
|
|
||||||
|
|
||||||
|
def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]:
|
||||||
|
odds = match.get("odds", {})
|
||||||
|
outcomes: list[dict[str, str | float]] = []
|
||||||
|
added: set[str] = set()
|
||||||
|
|
||||||
|
def append_outcome(code: str, name: str) -> None:
|
||||||
|
if name in odds and name not in added:
|
||||||
|
outcomes.append({"code": code, "name": name, "odds": odds[name]})
|
||||||
|
added.add(name)
|
||||||
|
|
||||||
|
home = match.get("home", "")
|
||||||
|
away = match.get("away", "")
|
||||||
|
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)
|
||||||
|
|
||||||
|
next_code = 3
|
||||||
|
for name in odds:
|
||||||
|
if name in added:
|
||||||
|
continue
|
||||||
|
append_outcome(str(next_code), name)
|
||||||
|
next_code += 1
|
||||||
|
|
||||||
|
return outcomes
|
||||||
|
|
||||||
|
|
||||||
|
def describe_match_outcomes(match: dict) -> str:
|
||||||
|
return ", ".join(
|
||||||
|
f"{item['code']}={item['name']} x{item['odds']:.2f}"
|
||||||
|
for item in _get_match_outcomes(match)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_match_outcome(match: dict, raw_choice: str) -> str | None:
|
||||||
|
choice = raw_choice.strip()
|
||||||
|
if not choice:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = choice.casefold()
|
||||||
|
outcomes = _get_match_outcomes(match)
|
||||||
|
|
||||||
|
for item in outcomes:
|
||||||
|
if normalized == str(item["code"]).casefold():
|
||||||
|
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:
|
||||||
|
if normalized == str(item["name"]).casefold():
|
||||||
|
return str(item["name"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
choice_odds = round(float(choice.replace(",", ".")), 2)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for item in outcomes:
|
||||||
|
if round(float(item["odds"]), 2) == choice_odds:
|
||||||
|
return str(item["name"])
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_matches(matches: list[dict], sport_alias: str, sport_label: str) -> str:
|
||||||
|
if not matches:
|
||||||
|
return f"🏟 {sport_label} [{sport_alias}] — матчей не найдено."
|
||||||
|
|
||||||
|
lines = [f"🏟 {sport_label} [{sport_alias}] — ближайшие матчи:\n"]
|
||||||
|
for i, m in enumerate(matches[:10], 1):
|
||||||
|
outcomes = _get_match_outcomes(m)
|
||||||
|
odds_str = "\n".join(
|
||||||
|
f" {item['code']}) {item['name']}: {item['odds']:.2f}"
|
||||||
|
for item in outcomes
|
||||||
|
)
|
||||||
|
|
||||||
|
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']}{start_str}\n{odds_str}\n")
|
||||||
|
|
||||||
|
lines.append(f"Ставка: /bet {sport_alias} [номер] [исход] [ставка]")
|
||||||
|
lines.append(f"Коротко после /matches {sport_alias}: /bet [номер] [исход] [ставка]")
|
||||||
|
lines.append("Исход: 1/2, для ничьи X. Можно писать ещё название команды или коэффициент.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sport_label(key: str) -> str:
|
||||||
|
labels = {
|
||||||
|
"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:
|
||||||
|
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)))
|
||||||
|
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
return f"Исход не найден. Доступные: {describe_match_outcomes(match)}"
|
||||||
|
|
||||||
|
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, payout, created_ts)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)""",
|
||||||
|
(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)
|
||||||
|
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} см\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_raw = float(r["payout"] or 0)
|
||||||
|
|
||||||
|
total_bet += amount
|
||||||
|
|
||||||
|
if status == "won":
|
||||||
|
wins += 1
|
||||||
|
# Для старых ставок payout может быть 0, пересчитываем
|
||||||
|
actual_payout = payout_raw if payout_raw > 0 else round(amount * odds_val, 1)
|
||||||
|
total_won += actual_payout
|
||||||
|
status_icon = "✅"
|
||||||
|
result_text = f"+{actual_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:
|
||||||
|
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
|
||||||
|
|
||||||
|
if not pending:
|
||||||
|
return notifications
|
||||||
|
|
||||||
|
now_ts = int(_time.time())
|
||||||
|
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
is_draw = False
|
||||||
|
|
||||||
|
score_values = []
|
||||||
|
for s in scores:
|
||||||
|
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:
|
||||||
|
max_score = score_val
|
||||||
|
winner = 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:
|
||||||
|
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', 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', 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']})"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
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)
|
||||||
127
games/casino.py
Normal file
127
games/casino.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import random
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Символы слотов и их веса (чем выше вес — тем чаще выпадает)
|
||||||
|
SLOT_SYMBOLS = ["🍒", "🍋", "🔔", "💎", "7️⃣", "🍀"]
|
||||||
|
SLOT_WEIGHTS = [30, 25, 20, 12, 8, 5] # 🍒 чаще всего, 🍀 реже всего
|
||||||
|
|
||||||
|
# Множители для выигрышей: {кол-во совпадений: (мин, макс)}
|
||||||
|
MULTIPLIERS = {
|
||||||
|
2: (1.2, 1.8), # две одинаковых
|
||||||
|
3: (2.0, 3.0), # три одинаковых (джекпот, редко)
|
||||||
|
}
|
||||||
|
|
||||||
|
CASINO_WIN_CHANCE = 0.35
|
||||||
|
|
||||||
|
|
||||||
|
def spin_slots() -> tuple[list[str], int]:
|
||||||
|
reels = random.choices(SLOT_SYMBOLS, weights=SLOT_WEIGHTS, k=3)
|
||||||
|
|
||||||
|
if random.random() > CASINO_WIN_CHANCE:
|
||||||
|
while len(set(reels)) < 3:
|
||||||
|
reels = random.choices(SLOT_SYMBOLS, weights=SLOT_WEIGHTS, k=3)
|
||||||
|
return reels, 0
|
||||||
|
|
||||||
|
counts = {}
|
||||||
|
for s in reels:
|
||||||
|
counts[s] = counts.get(s, 0) + 1
|
||||||
|
max_match = max(counts.values())
|
||||||
|
return reels, max_match
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_length(user_id: int) -> float | None:
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT length FROM penis_stats WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
return float(row[0]) if row else None
|
||||||
|
except sqlite3.Error:
|
||||||
|
logger.exception("casino: failed to get length")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_length(user_id: int, delta: float) -> float | None:
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT length FROM penis_stats WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
new_length = round(float(row[0]) + delta, 1)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE penis_stats SET length = ? WHERE user_id = ?",
|
||||||
|
(new_length, user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return new_length
|
||||||
|
except sqlite3.Error:
|
||||||
|
logger.exception("casino: failed to update length")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def play_casino(user_id: int, bet: float) -> str:
|
||||||
|
current = get_user_length(user_id)
|
||||||
|
if current is None:
|
||||||
|
return "Ты ещё ни разу не крутил /penis, братуха. Сначала заведи счёт."
|
||||||
|
|
||||||
|
# Проверяем отрицательный баланс
|
||||||
|
if current <= 0:
|
||||||
|
return "🚫 С кредитом в казино не впускают, братуха. Иди наращивай через /penis."
|
||||||
|
|
||||||
|
# Проверяем максимум ставки
|
||||||
|
if bet > config.CASINO_MAX_BET:
|
||||||
|
return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай."
|
||||||
|
|
||||||
|
if bet <= 0:
|
||||||
|
return "Ставка должна быть больше нуля, фраер."
|
||||||
|
|
||||||
|
# Проверяем хватает ли
|
||||||
|
if bet > current:
|
||||||
|
return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер."
|
||||||
|
|
||||||
|
# Крутим
|
||||||
|
reels, matches = spin_slots()
|
||||||
|
slots_display = " | ".join(reels)
|
||||||
|
|
||||||
|
if matches >= 2:
|
||||||
|
min_mult, max_mult = MULTIPLIERS[matches]
|
||||||
|
multiplier = round(random.uniform(min_mult, max_mult), 1)
|
||||||
|
winnings = round(bet * multiplier, 1)
|
||||||
|
new_length = update_user_length(user_id, winnings)
|
||||||
|
if new_length is None:
|
||||||
|
return "Ошибка БД. Попробуй позже."
|
||||||
|
|
||||||
|
if matches == 3:
|
||||||
|
return (
|
||||||
|
f"🎰 {slots_display}\n\n"
|
||||||
|
f"🎉 ДЖЕКПОТ!!! Три одинаковых!\n"
|
||||||
|
f"Множитель: x{multiplier}\n"
|
||||||
|
f"Выигрыш: +{winnings:.1f} см\n"
|
||||||
|
f"📏 Текущий размер: {new_length:.1f} см"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"🎰 {slots_display}\n\n"
|
||||||
|
f"✅ Выигрыш! Две совпали.\n"
|
||||||
|
f"Множитель: x{multiplier}\n"
|
||||||
|
f"Выигрыш: +{winnings:.1f} см\n"
|
||||||
|
f"📏 Текущий размер: {new_length:.1f} см"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
new_length = update_user_length(user_id, -bet)
|
||||||
|
if new_length is None:
|
||||||
|
return "Ошибка БД. Попробуй позже."
|
||||||
|
return (
|
||||||
|
f"🎰 {slots_display}\n\n"
|
||||||
|
f"❌ Мимо, братуха.\n"
|
||||||
|
f"Проигрыш: -{bet:.1f} см\n"
|
||||||
|
f"📏 Текущий размер: {new_length:.1f} см"
|
||||||
|
)
|
||||||
57
games/fortune.py
Normal file
57
games/fortune.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import logging
|
||||||
|
import aiohttp
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
||||||
|
|
||||||
|
FORTUNE_PROMPT = (
|
||||||
|
"Ты — уличный гадатель с района, базаришь на фене. "
|
||||||
|
"Пользователь хочет погадать на тему. Выдай шуточное гадание, "
|
||||||
|
"коротко и дерзко, 2-3 предложения максимум. "
|
||||||
|
"В конце ОБЯЗАТЕЛЬНО напиши на отдельной строке в формате:\n"
|
||||||
|
"MEM_UP: <текст сверху для мема, максимум 4 слова>\n"
|
||||||
|
"MEM_DOWN: <текст снизу для мема, максимум 4 слова>\n"
|
||||||
|
"Тексты для мема должны быть смешными и короткими."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_fortune(topic: str) -> tuple[str, str, str]:
|
||||||
|
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
|
||||||
|
payload = {
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": FORTUNE_PROMPT},
|
||||||
|
{"role": "user", "content": f"Погадай мне на: {topic}"},
|
||||||
|
],
|
||||||
|
"max_tokens": 200,
|
||||||
|
"temperature": 0.9,
|
||||||
|
"top_p": 0.9,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp:
|
||||||
|
data = await resp.json()
|
||||||
|
text = data["choices"][0]["message"]["content"].strip()
|
||||||
|
|
||||||
|
up_text = ""
|
||||||
|
down_text = ""
|
||||||
|
fortune_lines = []
|
||||||
|
|
||||||
|
for line in text.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.upper().startswith("MEM_UP:"):
|
||||||
|
up_text = stripped.split(":", 1)[1].strip()
|
||||||
|
elif stripped.upper().startswith("MEM_DOWN:"):
|
||||||
|
down_text = stripped.split(":", 1)[1].strip()
|
||||||
|
else:
|
||||||
|
fortune_lines.append(line)
|
||||||
|
|
||||||
|
fortune_text = "\n".join(fortune_lines).strip()
|
||||||
|
|
||||||
|
if not up_text:
|
||||||
|
up_text = "Гадание"
|
||||||
|
if not down_text:
|
||||||
|
down_text = "по фене"
|
||||||
|
|
||||||
|
return fortune_text, up_text, down_text
|
||||||
87
games/uwu.py
Normal file
87
games/uwu.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import aiohttp
|
||||||
|
from aiogram import types
|
||||||
|
import config
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CAPTIONS = [
|
||||||
|
"Ня :3",
|
||||||
|
"uwu"
|
||||||
|
]
|
||||||
|
|
||||||
|
async def handle_uwu_cmd(message: types.Message):
|
||||||
|
args = message.text.split(maxsplit=1)
|
||||||
|
|
||||||
|
if len(args) > 1 and args[1].strip() == "-h":
|
||||||
|
help_msg = (
|
||||||
|
"🐈 <b>Справка по тегам e621:</b>\n\n"
|
||||||
|
"Перечислите любые теги через пробел после команды.\n"
|
||||||
|
"• <b>rating</b>: <code>rating:safe</code> (безопасные), <code>rating:questionable</code>, <code>rating:explicit</code> (NSFW)\n"
|
||||||
|
"• <b>score</b>: <code>score:>500</code> (искать популярные посты с рейтингом больше 500)\n"
|
||||||
|
"• Исключить тег: поставьте минус, например <code>-fox</code>\n\n"
|
||||||
|
"<i>Пример:</i> <code>/uwu wolf -rating:explicit score:>100</code>\n"
|
||||||
|
"<i>По умолчанию:</i> <code>rating:safe score:>500 -animated</code>"
|
||||||
|
)
|
||||||
|
await message.reply(help_msg, parse_mode="HTML")
|
||||||
|
return
|
||||||
|
|
||||||
|
tags = "rating:safe score:>500 -animated"
|
||||||
|
if len(args) > 1:
|
||||||
|
tags = args[1].strip()
|
||||||
|
|
||||||
|
tags += " order:random"
|
||||||
|
|
||||||
|
url = "https://e621.net/posts.json"
|
||||||
|
params = {
|
||||||
|
"tags": tags,
|
||||||
|
"limit": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
auth = None
|
||||||
|
if config.E621_LOGIN and config.E621_API_KEY:
|
||||||
|
auth = aiohttp.BasicAuth(config.E621_LOGIN, config.E621_API_KEY)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"User-Agent": f"Sex Bomba TG Bot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, params=params, headers=headers, auth=auth) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
logger.error(f"e621 API Error: {resp.status}")
|
||||||
|
await message.reply("Не удалось получить картинку от сервера :(")
|
||||||
|
return
|
||||||
|
data = await resp.json()
|
||||||
|
|
||||||
|
posts = data.get("posts", [])
|
||||||
|
if not posts:
|
||||||
|
await message.reply("Ничего не найдено по этим тегам :(")
|
||||||
|
return
|
||||||
|
|
||||||
|
post = posts[0]
|
||||||
|
file_url = post.get("file", {}).get("url")
|
||||||
|
ext = post.get("file", {}).get("ext", "png")
|
||||||
|
if not file_url:
|
||||||
|
await message.reply("У найденного поста нет прямого URL изображения :(")
|
||||||
|
return
|
||||||
|
|
||||||
|
caption = random.choice(CAPTIONS)
|
||||||
|
if ext in ("gif", "webm", "mp4"):
|
||||||
|
# Для анимаций: отправляем caption отдельным сообщением,
|
||||||
|
# а саму гифку/видео через URL напрямую — так Telegram
|
||||||
|
# корректно показывает её как анимацию, а не как файл
|
||||||
|
await message.answer(caption)
|
||||||
|
await message.answer_animation(animation=file_url)
|
||||||
|
elif ext in ("png", "jpg", "jpeg", "webp"):
|
||||||
|
input_file = types.URLInputFile(file_url)
|
||||||
|
await message.answer_photo(photo=input_file, caption=caption)
|
||||||
|
else:
|
||||||
|
input_file = types.URLInputFile(file_url)
|
||||||
|
await message.answer_document(document=input_file, caption=caption)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error in uwu command")
|
||||||
|
await message.reply("Произошла ошибка при обращении к API :(")
|
||||||
BIN
maket/2.png
Normal file
BIN
maket/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 701 KiB |
BIN
maket/cvetok.jpg
Normal file
BIN
maket/cvetok.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
maket/vape.jpg
Normal file
BIN
maket/vape.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
|
|
@ -1,6 +1,8 @@
|
||||||
aiogram>=3.4.1
|
aiogram>=3.4.1
|
||||||
pillow>=10.3.0
|
pillow>=10.3.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
|
aiohttp>=3.9.5
|
||||||
|
tzdata>=2024.1
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
lxml
|
lxml
|
||||||
pysqlite3-binary>=0.5.3
|
pysqlite3-binary>=0.5.3
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue