forked from zovos/bot_tg
Compare commits
8 commits
9f6a7464d1
...
aee53349b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aee53349b1 | ||
|
|
a7c047f847 | ||
|
|
9295538f5f | ||
|
|
5b294415d0 | ||
|
|
43503dc778 | ||
|
|
75a63bb6fc | ||
|
|
682fc7422c | ||
| 373aa8f6c2 |
10 changed files with 1085 additions and 78 deletions
|
|
@ -5,3 +5,4 @@ __pycache__
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
db
|
||||||
|
|
|
||||||
1
.env
1
.env
|
|
@ -1,2 +1,3 @@
|
||||||
BOT_TOKEN=8488665890:AAH7FY5I2xzHhfOdxlzb11ewl2I-MMTH2-o
|
BOT_TOKEN=8488665890:AAH7FY5I2xzHhfOdxlzb11ewl2I-MMTH2-o
|
||||||
TZ=Europe/Moscow
|
TZ=Europe/Moscow
|
||||||
|
ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923
|
||||||
|
|
|
||||||
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"]
|
||||||
|
|
|
||||||
44
README.md
44
README.md
|
|
@ -4,20 +4,41 @@
|
||||||
- вписывает текст шрифтом Impact (белый с чёрной обводкой) в макеты (`/maket`);
|
- вписывает текст шрифтом Impact (белый с чёрной обводкой) в макеты (`/maket`);
|
||||||
- делает мемы из присланных фото (`/gen_mem Up:... Down:...`);
|
- делает мемы из присланных фото (`/gen_mem Up:... Down:...`);
|
||||||
- подсказывает, сколько минут до перекура по заданному расписанию (`/break`);
|
- подсказывает, сколько минут до перекура по заданному расписанию (`/break`);
|
||||||
|
- показывает расписание, текущую и следующую пару (`/schedule`, можно с днём недели);
|
||||||
- умеет базарить (`/talk`) с контекстом 100 сообщений;
|
- умеет базарить (`/talk`) с контекстом 100 сообщений;
|
||||||
- авто-ответы в чате (10% шанс + триггер-слова);
|
- авто-ответы в чате (10% шанс + триггер-слова);
|
||||||
- миниигра `/penis` + `/top_penis`;
|
- миниигра `/penis` + `/top_penis`;
|
||||||
- казино на размер (`/penis_casino`);
|
- казино на размер (`/penis_casino`);
|
||||||
- гадание на фене с мемом (`/gadanie`).
|
- гадание на фене с мемом (`/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`
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
|
|
@ -26,12 +47,17 @@ 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` — показывает, сколько минут до ближайшего перекура/идёт ли перекур.
|
||||||
|
- `/schedule [пн|mon]` — показывает расписание на ближайший указанный день недели; без аргумента показывает сегодня, текущую и следующую пару.
|
||||||
- `/talk <сообщение>` — побазарить с ИИ (контекст 100 сообщений).
|
- `/talk <сообщение>` — побазарить с ИИ (контекст 100 сообщений).
|
||||||
- `/penis` — миниигра, 1 раз в 24 часа.
|
- `/penis` — миниигра, 1 раз в 24 часа.
|
||||||
- `/top_penis` — топ по длине.
|
- `/top_penis` — топ по длине.
|
||||||
|
- `/polychaetsi` — топ по слову «получается».
|
||||||
- `/penis_casino <ставка>` — казино на размер (макс 1 см, казино всегда в выигрыше).
|
- `/penis_casino <ставка>` — казино на размер (макс 1 см, казино всегда в выигрыше).
|
||||||
- `/gadanie <тема>` — ИИ-гадание на фене + авто-мем.
|
- `/gadanie <тема>` — ИИ-гадание на фене + авто-мем.
|
||||||
- `/ebalnik` — вкл/выкл авто-ответы бота в чате.
|
- `/ebalnik` — вкл/выкл авто-ответы бота в чате.
|
||||||
|
- `/matches [cs|dota|football]` — ближайшие матчи с коэффициентами.
|
||||||
|
- `/bet <спорт> <номер> <команда> <ставка>` — поставить см на матч.
|
||||||
|
- `/mybets` — мои активные ставки.
|
||||||
- `/svodka` — сводка СВО.
|
- `/svodka` — сводка СВО.
|
||||||
|
|
||||||
## Макеты и шрифт
|
## Макеты и шрифт
|
||||||
|
|
@ -45,14 +71,22 @@ 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` считает по расписанию в `config.py`.
|
- `/break` считает по расписанию в `config.py` и показывает следующую пару.
|
||||||
|
- Названия пар и верхняя/нижняя неделя лежат в `config.py` в `LESSON_SCHEDULE`.
|
||||||
- `/talk` шлёт запрос к llama.cpp серверу с контекстом 100 сообщений (хранятся в SQLite).
|
- `/talk` шлёт запрос к llama.cpp серверу с контекстом 100 сообщений (хранятся в SQLite).
|
||||||
- `/penis_casino` — слоты с множителем x1.2–x3 (`games/casino.py`).
|
- `/penis_casino` — слоты с множителем x1.2–x3 (`games/casino.py`).
|
||||||
- `/gadanie` — ИИ-гадание + авто-мем через `generate_meme` (`games/fortune.py`).
|
- `/gadanie` — ИИ-гадание + авто-мем через `generate_meme` (`games/fortune.py`).
|
||||||
|
- `/matches` + `/bet` — ставки на реальные матчи, коэффициенты через The-Odds-API (`games/betting.py`).
|
||||||
|
|
||||||
## ИИ-модуль (`AI/talk_handler.py`)
|
## ИИ-модуль (`AI/talk_handler.py`)
|
||||||
|
|
||||||
|
|
|
||||||
109
config.py
109
config.py
|
|
@ -18,9 +18,36 @@ 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
|
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")
|
||||||
|
MAGNIT_REQUEST_TIMEOUT = int(os.getenv("MAGNIT_REQUEST_TIMEOUT", "15"))
|
||||||
|
# Можно указывать просто строку с названием или словарь с query/label/store_code.
|
||||||
|
MAGNIT_PRODUCTS = [
|
||||||
|
"RedBull",
|
||||||
|
]
|
||||||
|
|
||||||
# --- Конфигурация макетов ---
|
# --- Конфигурация макетов ---
|
||||||
TEMPLATES = {
|
TEMPLATES = {
|
||||||
"1": {
|
"1": {
|
||||||
|
|
@ -41,6 +68,81 @@ TEMPLATES = {
|
||||||
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),
|
||||||
|
|
@ -97,12 +199,17 @@ 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"
|
||||||
"• /penis_casino [ставка] — казино на размер.\n"
|
"• /penis_casino [ставка] — казино на размер.\n"
|
||||||
"• /gadanie [тема] — гадание на фене с мемом.\n"
|
"• /gadanie [тема] — гадание на фене с мемом.\n"
|
||||||
|
"• /matches [cs|dota|football] — матчи для ставок.\n"
|
||||||
|
"• /bet <спорт> <номер> <команда> <ставка> — поставить см.\n"
|
||||||
|
"• /mybets — мои ставки.\n"
|
||||||
"• /ebalnik — включить/выключить автоответы в чате.\n"
|
"• /ebalnik — включить/выключить автоответы в чате.\n"
|
||||||
"• /prices /fetch — цены.\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
|
||||||
|
|
|
||||||
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
|
||||||
659
main.py
659
main.py
|
|
@ -1,5 +1,6 @@
|
||||||
# Системные импорты
|
# Системные импорты
|
||||||
import asyncio, logging, os, re, random, sqlite3
|
import asyncio, json, logging, os, re, random, sqlite3, threading
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -21,11 +22,75 @@ from aiogram.client.default import DefaultBotProperties
|
||||||
from AI.talk_handler import handle_talk, generate_autoreply, push_message
|
from AI.talk_handler import handle_talk, generate_autoreply, push_message
|
||||||
from games.casino import play_casino
|
from games.casino import play_casino
|
||||||
from games.fortune import generate_fortune
|
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
|
from zparser import get_military_data
|
||||||
import config
|
import config
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
_polychaetsi_lock = threading.Lock()
|
||||||
|
_polychaetsi_word_re = re.compile(r"[а-яё]+", re.IGNORECASE)
|
||||||
|
_no_lesson_re = re.compile(r"(нету\s+пары|нет\s+пары|пары\s+нету|пары\s+нет)", re.IGNORECASE)
|
||||||
|
|
||||||
|
_weekday_names = {
|
||||||
|
0: "Понедельник",
|
||||||
|
1: "Вторник",
|
||||||
|
2: "Среда",
|
||||||
|
3: "Четверг",
|
||||||
|
4: "Пятница",
|
||||||
|
5: "Суббота",
|
||||||
|
6: "Воскресенье",
|
||||||
|
}
|
||||||
|
|
||||||
|
_weekday_aliases = {
|
||||||
|
"mon": 0,
|
||||||
|
"monday": 0,
|
||||||
|
"пн": 0,
|
||||||
|
"пон": 0,
|
||||||
|
"понедельник": 0,
|
||||||
|
"tue": 1,
|
||||||
|
"tues": 1,
|
||||||
|
"tuesday": 1,
|
||||||
|
"вт": 1,
|
||||||
|
"втр": 1,
|
||||||
|
"вторник": 1,
|
||||||
|
"wed": 2,
|
||||||
|
"wednesday": 2,
|
||||||
|
"ср": 2,
|
||||||
|
"срд": 2,
|
||||||
|
"среда": 2,
|
||||||
|
"thu": 3,
|
||||||
|
"thur": 3,
|
||||||
|
"thurs": 3,
|
||||||
|
"thursday": 3,
|
||||||
|
"чт": 3,
|
||||||
|
"чет": 3,
|
||||||
|
"четверг": 3,
|
||||||
|
"fri": 4,
|
||||||
|
"friday": 4,
|
||||||
|
"пт": 4,
|
||||||
|
"пят": 4,
|
||||||
|
"пятница": 4,
|
||||||
|
"sat": 5,
|
||||||
|
"saturday": 5,
|
||||||
|
"сб": 5,
|
||||||
|
"суб": 5,
|
||||||
|
"суббота": 5,
|
||||||
|
"sun": 6,
|
||||||
|
"sunday": 6,
|
||||||
|
"вс": 6,
|
||||||
|
"вск": 6,
|
||||||
|
"воскресенье": 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LessonSlot:
|
||||||
|
pair_number: int | None
|
||||||
|
title: str
|
||||||
|
start: time | None
|
||||||
|
end: time | None
|
||||||
|
is_free: bool
|
||||||
|
|
||||||
# --- Инициализация и вспомогательные функции ---
|
# --- Инициализация и вспомогательные функции ---
|
||||||
|
|
||||||
|
|
@ -46,6 +111,13 @@ def init_penis_db() -> None:
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_penis_stats_length ON penis_stats(length DESC)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_penis_stats_length ON penis_stats(length DESC)")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
def init_polychaetsi_storage() -> None:
|
||||||
|
"""Инициализация json-хранилища статистики слова 'получается'."""
|
||||||
|
path = config.POLYCHAETSI_STATS_PATH
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not path.exists():
|
||||||
|
path.write_text('{"users": {}}', encoding="utf-8")
|
||||||
|
|
||||||
def templates_list_text() -> str:
|
def templates_list_text() -> str:
|
||||||
"""Список макетов для подсказки."""
|
"""Список макетов для подсказки."""
|
||||||
parts = []
|
parts = []
|
||||||
|
|
@ -62,12 +134,144 @@ def _format_delta_ms(delta: timedelta) -> str:
|
||||||
seconds, milliseconds = divmod(rem, 1_000)
|
seconds, milliseconds = divmod(rem, 1_000)
|
||||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{milliseconds:03d}"
|
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{milliseconds:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_now(now: datetime | None = None) -> datetime:
|
||||||
|
if now is None:
|
||||||
|
return datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
||||||
|
if now.tzinfo is None:
|
||||||
|
return now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ))
|
||||||
|
return now
|
||||||
|
|
||||||
|
|
||||||
|
def _current_week_key(now: datetime) -> str:
|
||||||
|
week_number = now.isocalendar().week
|
||||||
|
is_upper = (week_number % 2 == 1) == config.UPPER_WEEK_IS_ODD
|
||||||
|
return "upper" if is_upper else "lower"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_schedule_date(now: datetime, requested_weekday: int | None = None) -> datetime:
|
||||||
|
if requested_weekday is None:
|
||||||
|
return now
|
||||||
|
delta_days = (requested_weekday - now.weekday()) % 7
|
||||||
|
return now + timedelta(days=delta_days)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_weekday_arg(value: str) -> int | None:
|
||||||
|
normalized = re.sub(r"[^a-zа-яё]", "", value.lower())
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
return _weekday_aliases.get(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_lesson_slot(raw_entry: dict) -> LessonSlot:
|
||||||
|
pair_number = raw_entry.get("pair")
|
||||||
|
start_t, end_t = config.PAIR_TIMES.get(pair_number, (None, None))
|
||||||
|
title = str(raw_entry.get("title", "")).strip() or "Пара не указана"
|
||||||
|
return LessonSlot(
|
||||||
|
pair_number=pair_number,
|
||||||
|
title=title,
|
||||||
|
start=start_t,
|
||||||
|
end=end_t,
|
||||||
|
is_free=bool(_no_lesson_re.search(title)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_lessons_for_day(now: datetime | None = None) -> tuple[str, list[LessonSlot]]:
|
||||||
|
now = _normalize_now(now)
|
||||||
|
week_key = _current_week_key(now)
|
||||||
|
raw_day = config.LESSON_SCHEDULE.get(week_key, {}).get(now.weekday(), [])
|
||||||
|
lessons = [_build_lesson_slot(item) for item in raw_day]
|
||||||
|
return week_key, lessons
|
||||||
|
|
||||||
|
|
||||||
|
def _lesson_bounds(now: datetime, lesson: LessonSlot) -> tuple[datetime | None, datetime | None]:
|
||||||
|
if lesson.start is None or lesson.end is None:
|
||||||
|
return None, None
|
||||||
|
start_dt = now.replace(hour=lesson.start.hour, minute=lesson.start.minute, second=0, microsecond=0)
|
||||||
|
end_dt = now.replace(hour=lesson.end.hour, minute=lesson.end.minute, second=0, microsecond=0)
|
||||||
|
return start_dt, end_dt
|
||||||
|
|
||||||
|
|
||||||
|
def _find_current_lesson(now: datetime, lessons: list[LessonSlot]) -> LessonSlot | None:
|
||||||
|
for lesson in lessons:
|
||||||
|
start_dt, end_dt = _lesson_bounds(now, lesson)
|
||||||
|
if start_dt and end_dt and start_dt <= now < end_dt:
|
||||||
|
return lesson
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_next_lesson(now: datetime, lessons: list[LessonSlot]) -> LessonSlot | None:
|
||||||
|
for lesson in lessons:
|
||||||
|
start_dt, _ = _lesson_bounds(now, lesson)
|
||||||
|
if start_dt and start_dt > now and not lesson.is_free:
|
||||||
|
return lesson
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_lesson_brief(lesson: LessonSlot) -> str:
|
||||||
|
prefix = f"{lesson.pair_number} пара" if lesson.pair_number is not None else "Пара"
|
||||||
|
if lesson.start and lesson.end:
|
||||||
|
return f"{prefix} ({lesson.start:%H:%M}-{lesson.end:%H:%M}) — {lesson.title}"
|
||||||
|
return f"{prefix} — {lesson.title}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_lesson_line(lesson: LessonSlot) -> str:
|
||||||
|
if lesson.pair_number is None:
|
||||||
|
return f"• {lesson.title}"
|
||||||
|
if lesson.start and lesson.end:
|
||||||
|
return f"{lesson.pair_number}. {lesson.start:%H:%M}-{lesson.end:%H:%M} — {lesson.title}"
|
||||||
|
return f"{lesson.pair_number}. {lesson.title}"
|
||||||
|
|
||||||
|
|
||||||
|
def next_lesson_text(now: datetime | None = None) -> str:
|
||||||
|
now = _normalize_now(now)
|
||||||
|
_, lessons = get_lessons_for_day(now)
|
||||||
|
next_lesson = _find_next_lesson(now, lessons)
|
||||||
|
if not next_lesson:
|
||||||
|
return "Следующей пары сегодня нет."
|
||||||
|
return f"Следующая пара: {_format_lesson_brief(next_lesson)}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_schedule_text(now: datetime | None = None, requested_weekday: int | None = None) -> str:
|
||||||
|
now = _normalize_now(now)
|
||||||
|
target_now = _resolve_schedule_date(now, requested_weekday)
|
||||||
|
week_key, lessons = get_lessons_for_day(target_now)
|
||||||
|
show_live_status = target_now.date() == now.date()
|
||||||
|
current_lesson = _find_current_lesson(now, lessons) if show_live_status else None
|
||||||
|
next_lesson = _find_next_lesson(now, lessons) if show_live_status else None
|
||||||
|
week_label = "Верхняя" if week_key == "upper" else "Нижняя"
|
||||||
|
day_label = _weekday_names.get(target_now.weekday(), "Сегодня")
|
||||||
|
if not show_live_status:
|
||||||
|
day_label = f"{day_label}, {target_now:%d.%m.%Y}"
|
||||||
|
|
||||||
|
lines = [f"{week_label} неделя", day_label]
|
||||||
|
if lessons:
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(_format_lesson_line(lesson) for lesson in lessons)
|
||||||
|
else:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("На этот день расписание пустое.")
|
||||||
|
|
||||||
|
if show_live_status:
|
||||||
|
lines.append("")
|
||||||
|
if current_lesson:
|
||||||
|
if current_lesson.is_free:
|
||||||
|
lines.append(f"Сейчас: {current_lesson.pair_number} пара свободна")
|
||||||
|
else:
|
||||||
|
lines.append(f"Сейчас: {_format_lesson_brief(current_lesson)}")
|
||||||
|
else:
|
||||||
|
lines.append("Сейчас: пары нет")
|
||||||
|
|
||||||
|
if next_lesson:
|
||||||
|
lines.append(f"Следующая: {_format_lesson_brief(next_lesson)}")
|
||||||
|
else:
|
||||||
|
lines.append("Следующая: сегодня нет")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
def minutes_until_break(now: datetime | None = None) -> str:
|
def minutes_until_break(now: datetime | None = None) -> str:
|
||||||
"""Логика расписания перекуров."""
|
"""Логика расписания перекуров."""
|
||||||
if now is None:
|
now = _normalize_now(now)
|
||||||
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
|
||||||
elif now.tzinfo is None:
|
|
||||||
now = now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ))
|
|
||||||
|
|
||||||
day = now.weekday()
|
day = now.weekday()
|
||||||
today_schedule = config.SCHEDULE.get(day, [])
|
today_schedule = config.SCHEDULE.get(day, [])
|
||||||
|
|
@ -94,10 +298,7 @@ def minutes_until_break(now: datetime | None = None) -> str:
|
||||||
|
|
||||||
def next_break_window(now: datetime | None = None) -> tuple[datetime | None, datetime | None]:
|
def next_break_window(now: datetime | None = None) -> tuple[datetime | None, datetime | None]:
|
||||||
"""Возвращает (начало, конец) ближайшего перекура или (None, None)."""
|
"""Возвращает (начало, конец) ближайшего перекура или (None, None)."""
|
||||||
if now is None:
|
now = _normalize_now(now)
|
||||||
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
|
||||||
elif now.tzinfo is None:
|
|
||||||
now = now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ))
|
|
||||||
|
|
||||||
day = now.weekday()
|
day = now.weekday()
|
||||||
today_schedule = config.SCHEDULE.get(day, [])
|
today_schedule = config.SCHEDULE.get(day, [])
|
||||||
|
|
@ -388,6 +589,83 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
||||||
minutes = (total_seconds % 3600) // 60
|
minutes = (total_seconds % 3600) // 60
|
||||||
return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин"
|
return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин"
|
||||||
|
|
||||||
|
def _prepare_magnit_query(value: str) -> str:
|
||||||
|
value = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", " ", value)
|
||||||
|
value = re.sub(r"[_-]+", " ", value)
|
||||||
|
return re.sub(r"\s+", " ", value).strip()
|
||||||
|
|
||||||
|
def _normalize_magnit_product(entry) -> dict[str, str]:
|
||||||
|
if isinstance(entry, str):
|
||||||
|
raw_query = entry.strip()
|
||||||
|
if not raw_query:
|
||||||
|
raise ValueError("Пустой query в MAGNIT_PRODUCTS.")
|
||||||
|
return {
|
||||||
|
"query": _prepare_magnit_query(raw_query),
|
||||||
|
"label": raw_query,
|
||||||
|
"store_code": config.MAGNIT_STORE_CODE,
|
||||||
|
}
|
||||||
|
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
raw_query = str(entry.get("query", "")).strip()
|
||||||
|
if not raw_query:
|
||||||
|
raise ValueError("У товара в MAGNIT_PRODUCTS отсутствует query.")
|
||||||
|
return {
|
||||||
|
"query": _prepare_magnit_query(raw_query),
|
||||||
|
"label": str(entry.get("label") or raw_query).strip() or raw_query,
|
||||||
|
"store_code": str(entry.get("store_code") or config.MAGNIT_STORE_CODE).strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
raise ValueError("MAGNIT_PRODUCTS должен содержать строки или словари.")
|
||||||
|
|
||||||
|
def _pick_magnit_items(data: dict) -> list[dict]:
|
||||||
|
items = data.get("items")
|
||||||
|
if isinstance(items, list):
|
||||||
|
return [item for item in items if isinstance(item, dict)]
|
||||||
|
|
||||||
|
best_match = data.get("best_match")
|
||||||
|
if isinstance(best_match, dict):
|
||||||
|
return [best_match]
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _format_money(value) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
amount = f"{float(value):.2f}".rstrip("0").rstrip(".")
|
||||||
|
return f"{amount} ₽"
|
||||||
|
|
||||||
|
def _format_magnit_price(item: dict) -> str:
|
||||||
|
new_price = _format_money(item.get("price"))
|
||||||
|
old_price = _format_money(item.get("old_price"))
|
||||||
|
if old_price and new_price and item.get("old_price") and item.get("old_price") != item.get("price"):
|
||||||
|
return f"{old_price} -> {new_price}"
|
||||||
|
return new_price or "нет цены"
|
||||||
|
|
||||||
|
def _format_magnit_quantity(value) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "нет данных"
|
||||||
|
return f"{value} шт."
|
||||||
|
|
||||||
|
async def _send_lines_in_chunks(message: Message, lines: list[str], limit: int = 4000) -> None:
|
||||||
|
if not lines:
|
||||||
|
await message.answer("Товары не найдены.")
|
||||||
|
return
|
||||||
|
|
||||||
|
chunk: list[str] = []
|
||||||
|
chunk_len = 0
|
||||||
|
for line in lines:
|
||||||
|
extra_len = len(line) + (1 if chunk else 0)
|
||||||
|
if chunk and chunk_len + extra_len > limit:
|
||||||
|
await message.answer("\n".join(chunk))
|
||||||
|
chunk = [line]
|
||||||
|
chunk_len = len(line)
|
||||||
|
continue
|
||||||
|
chunk.append(line)
|
||||||
|
chunk_len += extra_len
|
||||||
|
|
||||||
|
if chunk:
|
||||||
|
await message.answer("\n".join(chunk))
|
||||||
|
|
||||||
# --- Игровая логика (/penis) ---
|
# --- Игровая логика (/penis) ---
|
||||||
|
|
||||||
def play_penis(user_id: int, user_name: str | None = None, now: datetime | None = None) -> tuple[bool, str]:
|
def play_penis(user_id: int, user_name: str | None = None, now: datetime | None = None) -> tuple[bool, str]:
|
||||||
|
|
@ -495,6 +773,161 @@ def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str:
|
||||||
lines.append(f"{prefix} {name} — {length:.1f} см")
|
lines.append(f"{prefix} {name} — {length:.1f} см")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _load_polychaetsi_stats() -> dict:
|
||||||
|
path = config.POLYCHAETSI_STATS_PATH
|
||||||
|
if not path.exists():
|
||||||
|
return {"users": {}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
logger.exception("Failed to load polychaetsi stats")
|
||||||
|
return {"users": {}}
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {"users": {}}
|
||||||
|
|
||||||
|
users = data.get("users")
|
||||||
|
if not isinstance(users, dict):
|
||||||
|
data["users"] = {}
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _save_polychaetsi_stats(data: dict) -> None:
|
||||||
|
path = config.POLYCHAETSI_STATS_PATH
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = path.with_name(f"{path.name}.tmp")
|
||||||
|
with tmp_path.open("w", encoding="utf-8") as fh:
|
||||||
|
json.dump(data, fh, ensure_ascii=False, indent=2)
|
||||||
|
tmp_path.replace(path)
|
||||||
|
|
||||||
|
def _damerau_levenshtein_distance(source: str, target: str, max_distance: int = 2) -> int:
|
||||||
|
if source == target:
|
||||||
|
return 0
|
||||||
|
if abs(len(source) - len(target)) > max_distance:
|
||||||
|
return max_distance + 1
|
||||||
|
|
||||||
|
previous_previous: list[int] | None = None
|
||||||
|
previous = list(range(len(target) + 1))
|
||||||
|
|
||||||
|
for i, source_char in enumerate(source, start=1):
|
||||||
|
current = [i] + [0] * len(target)
|
||||||
|
row_min = current[0]
|
||||||
|
|
||||||
|
for j, target_char in enumerate(target, start=1):
|
||||||
|
cost = 0 if source_char == target_char else 1
|
||||||
|
current[j] = min(
|
||||||
|
previous[j] + 1,
|
||||||
|
current[j - 1] + 1,
|
||||||
|
previous[j - 1] + cost,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
previous_previous is not None
|
||||||
|
and i > 1
|
||||||
|
and j > 1
|
||||||
|
and source_char == target[j - 2]
|
||||||
|
and source[i - 2] == target_char
|
||||||
|
):
|
||||||
|
current[j] = min(current[j], previous_previous[j - 2] + 1)
|
||||||
|
|
||||||
|
row_min = min(row_min, current[j])
|
||||||
|
|
||||||
|
if row_min > max_distance:
|
||||||
|
return max_distance + 1
|
||||||
|
|
||||||
|
previous_previous, previous = previous, current
|
||||||
|
|
||||||
|
return previous[-1]
|
||||||
|
|
||||||
|
def _normalize_polychaetsi_word(word: str) -> str:
|
||||||
|
normalized = word.lower().replace("ё", "е")
|
||||||
|
# Подтягиваем самые частые ошибки в возвратном окончании.
|
||||||
|
return re.sub(r"(ть?ся|ть?са|цца|ца)$", "тся", normalized)
|
||||||
|
|
||||||
|
def is_polychaetsi_variant(word: str) -> bool:
|
||||||
|
normalized = _normalize_polychaetsi_word(word)
|
||||||
|
target = config.POLYCHAETSI_TARGET
|
||||||
|
|
||||||
|
if normalized == target:
|
||||||
|
return True
|
||||||
|
if not normalized or normalized[0] != "п":
|
||||||
|
return False
|
||||||
|
if len(normalized) < len(target) - 1 or len(normalized) > len(target) + 1:
|
||||||
|
return False
|
||||||
|
if "ч" not in normalized or "а" not in normalized or "е" not in normalized:
|
||||||
|
return False
|
||||||
|
if not normalized.endswith(("я", "а")):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return _damerau_levenshtein_distance(normalized, target, max_distance=2) <= 2
|
||||||
|
|
||||||
|
def count_polychaetsi_mentions(text: str) -> int:
|
||||||
|
return sum(1 for word in _polychaetsi_word_re.findall(text) if is_polychaetsi_variant(word))
|
||||||
|
|
||||||
|
def register_polychaetsi_hits(user: types.User, hits: int) -> int:
|
||||||
|
display_name = format_penis_user_name(user)
|
||||||
|
with _polychaetsi_lock:
|
||||||
|
data = _load_polychaetsi_stats()
|
||||||
|
users = data.setdefault("users", {})
|
||||||
|
|
||||||
|
user_key = str(user.id)
|
||||||
|
entry = users.get(user_key)
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
entry = {"display_name": display_name, "count": 0}
|
||||||
|
|
||||||
|
stored_count = entry.get("count", 0)
|
||||||
|
try:
|
||||||
|
count = int(stored_count)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
count += hits
|
||||||
|
entry["display_name"] = display_name
|
||||||
|
entry["count"] = count
|
||||||
|
users[user_key] = entry
|
||||||
|
_save_polychaetsi_stats(data)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def build_polychaetsi_top(limit: int = config.POLYCHAETSI_TOP_LIMIT) -> str:
|
||||||
|
with _polychaetsi_lock:
|
||||||
|
data = _load_polychaetsi_stats()
|
||||||
|
|
||||||
|
users = data.get("users", {})
|
||||||
|
if not isinstance(users, dict):
|
||||||
|
return "ТОП получается:\nпока пусто."
|
||||||
|
|
||||||
|
entries: list[tuple[int, str, int]] = []
|
||||||
|
for user_key, raw_entry in users.items():
|
||||||
|
if not isinstance(raw_entry, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
count = int(raw_entry.get("count", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if count <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
numeric_user_id = int(user_key)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
numeric_user_id = 0
|
||||||
|
|
||||||
|
name = str(raw_entry.get("display_name") or f"user_{user_key}").strip() or f"user_{user_key}"
|
||||||
|
entries.append((count, name, numeric_user_id))
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
return "ТОП получается:\nпока пусто."
|
||||||
|
|
||||||
|
entries.sort(key=lambda item: (-item[0], item[1].lower(), item[2]))
|
||||||
|
medals = {1: "🥇", 2: "🥈", 3: "🥉"}
|
||||||
|
lines = ["ТОП получается:"]
|
||||||
|
for index, (count, name, _) in enumerate(entries[:limit], start=1):
|
||||||
|
prefix = medals.get(index, f"{index}.")
|
||||||
|
lines.append(f"{prefix} {name} — {count}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
# --- Хендлеры aiogram ---
|
# --- Хендлеры aiogram ---
|
||||||
|
|
||||||
async def handle_help(message: Message):
|
async def handle_help(message: Message):
|
||||||
|
|
@ -503,9 +936,24 @@ async def handle_help(message: Message):
|
||||||
async def handle_start(message: Message):
|
async def handle_start(message: Message):
|
||||||
await message.answer("Соси")
|
await message.answer("Соси")
|
||||||
|
|
||||||
|
async def handle_schedule_cmd(message: Message):
|
||||||
|
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
||||||
|
raw_text = message.text or ""
|
||||||
|
parts = raw_text.split(maxsplit=1)
|
||||||
|
requested_weekday = None
|
||||||
|
if len(parts) > 1:
|
||||||
|
requested_weekday = _parse_weekday_arg(parts[1].split(maxsplit=1)[0])
|
||||||
|
if requested_weekday is None:
|
||||||
|
await message.answer(
|
||||||
|
"Не понял день недели. Примеры: /schedule mon, /schedule fri, /schedule пн, /schedule чт."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await message.answer(build_schedule_text(now, requested_weekday))
|
||||||
|
|
||||||
async def handle_break_cmd(message: Message):
|
async def handle_break_cmd(message: Message):
|
||||||
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
||||||
await message.answer(minutes_until_break(now))
|
await message.answer(f"{minutes_until_break(now)}\n{next_lesson_text(now)}")
|
||||||
|
|
||||||
# Ставим будильник на ближайший перекур для этого чата
|
# Ставим будильник на ближайший перекур для этого чата
|
||||||
start_dt, _ = next_break_window(now)
|
start_dt, _ = next_break_window(now)
|
||||||
|
|
@ -522,6 +970,9 @@ async def handle_penis_cmd(message: Message):
|
||||||
async def handle_top_penis_cmd(message: Message):
|
async def handle_top_penis_cmd(message: Message):
|
||||||
await message.answer(build_penis_top())
|
await message.answer(build_penis_top())
|
||||||
|
|
||||||
|
async def handle_polychaetsi_cmd(message: Message):
|
||||||
|
await message.answer(build_polychaetsi_top())
|
||||||
|
|
||||||
async def handle_size_cmd(message: Message):
|
async def handle_size_cmd(message: Message):
|
||||||
size = round(random.uniform(0.1, 35.0), 1)
|
size = round(random.uniform(0.1, 35.0), 1)
|
||||||
if size < 5:
|
if size < 5:
|
||||||
|
|
@ -542,61 +993,62 @@ async def handle_zvetok_cmd(message: Message):
|
||||||
await message.answer(minutes_until_next_lesson())
|
await message.answer(minutes_until_next_lesson())
|
||||||
|
|
||||||
async def handle_prices_cmd(message: Message):
|
async def handle_prices_cmd(message: Message):
|
||||||
|
try:
|
||||||
|
products = [_normalize_magnit_product(entry) for entry in config.MAGNIT_PRODUCTS]
|
||||||
|
except ValueError as exc:
|
||||||
|
await message.answer(f"Проверь MAGNIT_PRODUCTS в config.py: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not products:
|
||||||
|
await message.answer("Список MAGNIT_PRODUCTS пуст. Добавь товары в config.py.")
|
||||||
|
return
|
||||||
|
|
||||||
|
async def fetch_product(product_cfg: dict[str, str]) -> list[str]:
|
||||||
def fetch():
|
def fetch():
|
||||||
return requests.get(
|
return requests.get(
|
||||||
"https://bebrik.xyz/magnit/products",
|
f"{config.MAGNIT_API_BASE_URL.rstrip('/')}/price",
|
||||||
headers={"X-Token": "sisi"},
|
params={
|
||||||
timeout=10,
|
"store_code": product_cfg["store_code"],
|
||||||
|
"query": product_cfg["query"],
|
||||||
|
},
|
||||||
|
timeout=config.MAGNIT_REQUEST_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
label = product_cfg["label"] or product_cfg["query"] or "товар"
|
||||||
try:
|
try:
|
||||||
resp = await asyncio.to_thread(fetch)
|
resp = await asyncio.to_thread(fetch)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
await message.answer("пошел нахуй")
|
logger.warning("Magnit API returned %s for %s", resp.status_code, product_cfg["query"])
|
||||||
return
|
return [f"{label}, ошибка API ({resp.status_code}), -"]
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
if not data:
|
if not isinstance(data, dict):
|
||||||
await message.answer("иди нахуй")
|
return [f"{label}, битый ответ API, -"]
|
||||||
return
|
|
||||||
text = "смерть в 25:\n\n"
|
items = _pick_magnit_items(data)
|
||||||
for item in data:
|
if not items:
|
||||||
name = item.get("name")
|
return [f"{label}, товар не найден, -"]
|
||||||
price = item.get("price")
|
|
||||||
text += f"{name} — {price} ₽\n"
|
lines = []
|
||||||
await message.answer(text[:4000])
|
for item in items:
|
||||||
except Exception:
|
name = str(item.get("name") or label).strip() or label
|
||||||
await message.answer("unable to connect x-server гандон")
|
price = _format_magnit_price(item)
|
||||||
|
quantity = _format_magnit_quantity(item.get("quantity"))
|
||||||
|
lines.append(f"{name}, {price}, {quantity}")
|
||||||
|
return lines
|
||||||
|
except requests.RequestException:
|
||||||
|
logger.exception("Failed to fetch Magnit product: %s", product_cfg["query"])
|
||||||
|
return [f"{label}, ошибка запроса, -"]
|
||||||
|
except ValueError:
|
||||||
|
logger.exception("Magnit API returned invalid JSON for %s", product_cfg["query"])
|
||||||
|
return [f"{label}, битый JSON, -"]
|
||||||
|
|
||||||
|
result_groups = await asyncio.gather(*(fetch_product(product) for product in products))
|
||||||
|
lines = [line for group in result_groups for line in group]
|
||||||
|
await _send_lines_in_chunks(message, lines)
|
||||||
|
|
||||||
async def handle_fetch_cmd(message: Message):
|
async def handle_fetch_cmd(message: Message):
|
||||||
def fetch():
|
await handle_prices_cmd(message)
|
||||||
return requests.get(
|
|
||||||
"https://mirror.porno4free.ru/magnit/magnit/prices/all",
|
|
||||||
headers={"x-token": "secret_token"},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
resp = await asyncio.to_thread(fetch)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
await message.reply("пошел нахуй")
|
|
||||||
return
|
|
||||||
data = resp.json()
|
|
||||||
if not isinstance(data, dict) or not data:
|
|
||||||
await message.reply("иди нахуй")
|
|
||||||
return
|
|
||||||
lines = ["смерть в 25:"]
|
|
||||||
for group in sorted(data.keys()):
|
|
||||||
items = data.get(group)
|
|
||||||
lines.append(f"\n{group}:")
|
|
||||||
if not isinstance(items, list):
|
|
||||||
continue
|
|
||||||
for item in items:
|
|
||||||
name = item.get("name")
|
|
||||||
price = item.get("price")
|
|
||||||
if not name or price is None:
|
|
||||||
continue
|
|
||||||
lines.append(f"{name} — {price} ₽")
|
|
||||||
await message.answer("\n".join(lines)[:4000])
|
|
||||||
except Exception:
|
|
||||||
await message.answer("unable to connect x-server гандон")
|
|
||||||
|
|
||||||
async def handle_svodka_cmd(message: Message):
|
async def handle_svodka_cmd(message: Message):
|
||||||
data = await asyncio.to_thread(get_military_data, "creamy_caprice", 5)
|
data = await asyncio.to_thread(get_military_data, "creamy_caprice", 5)
|
||||||
|
|
@ -687,6 +1139,20 @@ async def handle_keywords(message: Message):
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
polychaetsi_hits = count_polychaetsi_mentions(text_lower)
|
||||||
|
if polychaetsi_hits > 0 and message.from_user:
|
||||||
|
total_hits = await asyncio.to_thread(register_polychaetsi_hits, message.from_user, polychaetsi_hits)
|
||||||
|
photo_path = config.POLYCHAETSI_IMAGE_PATH
|
||||||
|
if photo_path.exists():
|
||||||
|
await message.answer_photo(
|
||||||
|
photo=FSInputFile(photo_path),
|
||||||
|
caption=f"x{total_hits}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("cvetok.jpg not found, fallback to text response")
|
||||||
|
await message.answer(f"x{total_hits}")
|
||||||
|
return
|
||||||
|
|
||||||
# Записываем каждое обычное сообщение в историю чата
|
# Записываем каждое обычное сообщение в историю чата
|
||||||
await asyncio.to_thread(push_message, chat_id, "user", user_name, message.text)
|
await asyncio.to_thread(push_message, chat_id, "user", user_name, message.text)
|
||||||
|
|
||||||
|
|
@ -750,6 +1216,67 @@ async def handle_gadanie_cmd(message: Message):
|
||||||
logger.exception("Gadanie failed")
|
logger.exception("Gadanie failed")
|
||||||
await message.reply("Бля, карты рассыпались, попробуй позже.")
|
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():
|
def main():
|
||||||
|
|
@ -757,17 +1284,20 @@ def main():
|
||||||
if not token: raise RuntimeError("BOT_TOKEN не задан!")
|
if not token: raise RuntimeError("BOT_TOKEN не задан!")
|
||||||
|
|
||||||
init_penis_db()
|
init_penis_db()
|
||||||
|
init_polychaetsi_storage()
|
||||||
bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||||
dp = Dispatcher()
|
dp = Dispatcher()
|
||||||
|
|
||||||
# Регистрация хендлеров
|
# Регистрация хендлеров
|
||||||
dp.message.register(handle_start, CommandStart())
|
dp.message.register(handle_start, CommandStart())
|
||||||
dp.message.register(handle_help, Command("help"))
|
dp.message.register(handle_help, Command("help"))
|
||||||
|
dp.message.register(handle_schedule_cmd, Command("schedule"))
|
||||||
dp.message.register(handle_break_cmd, Command("break"))
|
dp.message.register(handle_break_cmd, Command("break"))
|
||||||
dp.message.register(handle_maket_cmd, Command("maket"))
|
dp.message.register(handle_maket_cmd, Command("maket"))
|
||||||
dp.message.register(handle_size_cmd, Command("size"))
|
dp.message.register(handle_size_cmd, Command("size"))
|
||||||
dp.message.register(handle_penis_cmd, Command("penis"))
|
dp.message.register(handle_penis_cmd, Command("penis"))
|
||||||
dp.message.register(handle_top_penis_cmd, Command("top_penis"))
|
dp.message.register(handle_top_penis_cmd, Command("top_penis"))
|
||||||
|
dp.message.register(handle_polychaetsi_cmd, Command("polychaetsi"))
|
||||||
dp.message.register(handle_prices_cmd, Command("prices"))
|
dp.message.register(handle_prices_cmd, Command("prices"))
|
||||||
dp.message.register(handle_fetch_cmd, Command("fetch"))
|
dp.message.register(handle_fetch_cmd, Command("fetch"))
|
||||||
dp.message.register(handle_zvetok_cmd, Command("zvetok"))
|
dp.message.register(handle_zvetok_cmd, Command("zvetok"))
|
||||||
|
|
@ -776,6 +1306,9 @@ def main():
|
||||||
dp.message.register(handle_ebalnik_cmd, Command("ebalnik"))
|
dp.message.register(handle_ebalnik_cmd, Command("ebalnik"))
|
||||||
dp.message.register(handle_penis_casino_cmd, Command("penis_casino"))
|
dp.message.register(handle_penis_casino_cmd, Command("penis_casino"))
|
||||||
dp.message.register(handle_gadanie_cmd, Command("gadanie"))
|
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_gen_mem, Command("gen_mem"))
|
||||||
dp.message.register(handle_keywords, F.text)
|
dp.message.register(handle_keywords, F.text)
|
||||||
|
|
||||||
|
|
@ -784,19 +1317,24 @@ def main():
|
||||||
commands = [
|
commands = [
|
||||||
BotCommand(command="start", description="Приветствие"),
|
BotCommand(command="start", description="Приветствие"),
|
||||||
BotCommand(command="help", description="Подсказки по командам"),
|
BotCommand(command="help", description="Подсказки по командам"),
|
||||||
|
BotCommand(command="schedule", description="расписание пар"),
|
||||||
BotCommand(command="maket", description="Вписать текст в макет 1"),
|
BotCommand(command="maket", description="Вписать текст в макет 1"),
|
||||||
BotCommand(command="gen_mem", description="Мем из фото (ответом)"),
|
BotCommand(command="gen_mem", description="Мем из фото (ответом)"),
|
||||||
BotCommand(command="break", description="Сколько минут до перекура"),
|
BotCommand(command="break", description="Сколько минут до перекура"),
|
||||||
BotCommand(command="size", description="хуй"),
|
BotCommand(command="size", description="хуй"),
|
||||||
BotCommand(command="penis", description="миниигра 1 раз в 24ч"),
|
BotCommand(command="penis", description="миниигра 1 раз в 24ч"),
|
||||||
BotCommand(command="top_penis", description="топ по длине"),
|
BotCommand(command="top_penis", description="топ по длине"),
|
||||||
BotCommand(command="prices", description="цены x-server"),
|
BotCommand(command="polychaetsi", description="топ по получается"),
|
||||||
BotCommand(command="fetch", description="цены (новый источник)"),
|
BotCommand(command="prices", description="цены и остатки Магнита"),
|
||||||
|
BotCommand(command="fetch", description="alias для /prices"),
|
||||||
BotCommand(command="zvetok", description="Цветянский бля"),
|
BotCommand(command="zvetok", description="Цветянский бля"),
|
||||||
BotCommand(command="talk", description="Побазарить"),
|
BotCommand(command="talk", description="Побазарить"),
|
||||||
BotCommand(command="ebalnik", description="вкл/выкл автответов"),
|
BotCommand(command="ebalnik", description="вкл/выкл автответов"),
|
||||||
BotCommand(command="penis_casino", description="казино на размер"),
|
BotCommand(command="penis_casino", description="казино на размер"),
|
||||||
BotCommand(command="gadanie", description="гадание на фене"),
|
BotCommand(command="gadanie", description="гадание на фене"),
|
||||||
|
BotCommand(command="matches", description="матчи для ставок"),
|
||||||
|
BotCommand(command="bet", description="поставить см на матч"),
|
||||||
|
BotCommand(command="mybets", description="мои ставки"),
|
||||||
BotCommand(command="svodka", description="СВО: итоги"),
|
BotCommand(command="svodka", description="СВО: итоги"),
|
||||||
]
|
]
|
||||||
scopes = (
|
scopes = (
|
||||||
|
|
@ -810,6 +1348,11 @@ def main():
|
||||||
logger.warning("Не удалось выставить команды, но бот продолжит работу.")
|
logger.warning("Не удалось выставить команды, но бот продолжит работу.")
|
||||||
|
|
||||||
dp.startup.register(on_startup)
|
dp.startup.register(on_startup)
|
||||||
|
|
||||||
|
async def start_settle(bot: Bot):
|
||||||
|
asyncio.create_task(_settle_loop(bot))
|
||||||
|
dp.startup.register(start_settle)
|
||||||
|
|
||||||
logger.info("Бот запущен...")
|
logger.info("Бот запущен...")
|
||||||
asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()))
|
asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()))
|
||||||
|
|
||||||
|
|
|
||||||
BIN
maket/cvetok.jpg
Normal file
BIN
maket/cvetok.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 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