1
0
Fork 0
forked from zovos/bot_tg

Compare commits

..

No commits in common. "aee53349b13aa74afac12ef77b6287a35d6135ce" and "9f6a7464d1591d52c33d39fbc9d08084eaa2be13" have entirely different histories.

10 changed files with 78 additions and 1085 deletions

View file

@ -5,4 +5,3 @@ __pycache__
.idea
.vscode
.DS_Store
db

1
.env
View file

@ -1,3 +1,2 @@
BOT_TOKEN=8488665890:AAH7FY5I2xzHhfOdxlzb11ewl2I-MMTH2-o
TZ=Europe/Moscow
ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923

View file

@ -1,15 +1,9 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt ./
RUN python -m pip install --upgrade pip \
&& python -m pip install -r requirements.txt
COPY . .
RUN mkdir -p /db
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py config.py impact.ttf zparser.py ./
COPY maket ./maket
COPY AI ./AI
CMD ["python", "main.py"]

View file

@ -4,41 +4,20 @@
- вписывает текст шрифтом Impact (белый с чёрной обводкой) в макеты (`/maket`);
- делает мемы из присланных фото (`/gen_mem Up:... Down:...`);
- подсказывает, сколько минут до перекура по заданному расписанию (`/break`);
- показывает расписание, текущую и следующую пару (`/schedule`, можно с днём недели);
- умеет базарить (`/talk`) с контекстом 100 сообщений;
- авто-ответы в чате (10% шанс + триггер-слова);
- миниигра `/penis` + `/top_penis`;
- казино на размер (`/penis_casino`);
- гадание на фене с мемом (`/gadanie`);
- ставки на матчи CS2, Dota 2, футбол с реальными коэффициентами (`/matches`, `/bet`).
- гадание на фене с мемом (`/gadanie`).
## Запуск
```bash
docker compose up --build -d
docker-compose up --build
```
Логи:
```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`
Переменные окружения: задайте `BOT_TOKEN` в `.env` (уже добавлен шаблон);
`LLAMA_API_URL` — адрес llama.cpp сервера (для `/talk`)
## Команды
@ -47,17 +26,12 @@ docker compose down
- `/maket <id> <текст>` — вписать текст в макет. Доступные: `1 — Владимир Владимирович Путин самый лучший президент` (737x414, точка 104,240).
- `/gen_mem Up:верх Down:низ` — прислать с фото (подпись) или ответом на фото. Можно указывать только Up или только Down.
- `/break` — показывает, сколько минут до ближайшего перекура/идёт ли перекур.
- `/schedule [пн|mon]` — показывает расписание на ближайший указанный день недели; без аргумента показывает сегодня, текущую и следующую пару.
- `/talk <сообщение>` — побазарить с ИИ (контекст 100 сообщений).
- `/penis` — миниигра, 1 раз в 24 часа.
- `/top_penis` — топ по длине.
- `/polychaetsi` — топ по слову «получается».
- `/penis_casino <ставка>` — казино на размер (макс 1 см, казино всегда в выигрыше).
- `/gadanie <тема>` — ИИ-гадание на фене + авто-мем.
- `/ebalnik` — вкл/выкл авто-ответы бота в чате.
- `/matches [cs|dota|football]` — ближайшие матчи с коэффициентами.
- `/bet <спорт> <номер> <команда> <ставка>` — поставить см на матч.
- `/mybets` — мои активные ставки.
- `/svodka` — сводка СВО.
## Макеты и шрифт
@ -71,22 +45,14 @@ docker compose down
- llama.cpp для ИИ-фич.
- Dockerfile + docker-compose для развёртывания.
## Docker Notes
- Образ ставит `tzdata`, поэтому `ZoneInfo("Europe/Moscow")` нормально работает в контейнере.
- В образ копируется весь проект, а runtime-данные исключены через `.dockerignore`.
- Если меняешь код или картинки, достаточно пересобрать: `docker compose up --build -d`.
## Кратко о логике
- `/maket` подбирает размер текста, чтобы уместить его в область макета.
- `/gen_mem` центрирует текст по ширине, масштабирует под изображение, рисует белый текст с чёрной обводкой.
- `/break` считает по расписанию в `config.py` и показывает следующую пару.
- Названия пар и верхняя/нижняя неделя лежат в `config.py` в `LESSON_SCHEDULE`.
- `/break` считает по расписанию в `config.py`.
- `/talk` шлёт запрос к llama.cpp серверу с контекстом 100 сообщений (хранятся в SQLite).
- `/penis_casino` — слоты с множителем x1.2x3 (`games/casino.py`).
- `/gadanie` — ИИ-гадание + авто-мем через `generate_meme` (`games/fortune.py`).
- `/matches` + `/bet` — ставки на реальные матчи, коэффициенты через The-Odds-API (`games/betting.py`).
## ИИ-модуль (`AI/talk_handler.py`)

109
config.py
View file

@ -18,36 +18,9 @@ PENIS_COOLDOWN = timedelta(hours=24)
PENIS_COOLDOWN_SECONDS = int(PENIS_COOLDOWN.total_seconds())
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
# --- Ставки на матчи ---
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 = {
"1": {
@ -68,81 +41,6 @@ TEMPLATES = {
def make_time(h: int, m: int) -> time:
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 = {
0: [ # Понедельник
(make_time(9, 50), make_time(10, 35), 5),
@ -199,17 +97,12 @@ def get_hint_text(templates_str: str) -> str:
f"• /maket «id» «текст» — доступные: {templates_str}.\n"
"• /gen_mem Up:верх Down:низ — отправь с фото.\n"
"• /break — узнать время до перекура.\n"
"• /schedule [пн|mon] — расписание; без аргумента еще текущая и следующая пара.\n"
"• /penis — миниигра 1 раз в 24 часа.\n"
"• /top_penis — топ по длине.\n"
"• /talk [текст] — ИИ базарит на фене.\n"
"• /penis_casino [ставка] — казино на размер.\n"
"• /gadanie [тема] — гадание на фене с мемом.\n"
"• /matches [cs|dota|football] — матчи для ставок.\n"
"• /bet <спорт> <номер> <команда> <ставка> — поставить см.\n"
"• /mybets — мои ставки.\n"
"• /ebalnik — включить/выключить автоответы в чате.\n"
"• /polychaetsi — топ по слову «получается».\n"
"• /prices /fetch — цены и остатки Магнита.\n"
"• /prices /fetch — цены.\n"
"Подсказка: в группах пиши /команда@username_бота."
)

View file

@ -1,15 +1,10 @@
version: "3.9"
services:
bot:
build:
context: .
build: .
volumes:
- ./db:/db
env_file:
- .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

View file

@ -1,308 +0,0 @@
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
View file

@ -1,6 +1,5 @@
# Системные импорты
import asyncio, json, logging, os, re, random, sqlite3, threading
from dataclasses import dataclass
import asyncio, logging, os, re, random, sqlite3
from datetime import datetime, time, timedelta
from io import BytesIO
from pathlib import Path
@ -22,75 +21,11 @@ from aiogram.client.default import DefaultBotProperties
from AI.talk_handler import handle_talk, generate_autoreply, push_message
from games.casino import play_casino
from games.fortune import generate_fortune
from games.betting import get_formatted_matches, get_match_by_index, place_bet, get_user_bets, settle_bets
from zparser import get_military_data
import config
logging.basicConfig(level=logging.INFO)
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
# --- Инициализация и вспомогательные функции ---
@ -111,13 +46,6 @@ def init_penis_db() -> None:
conn.execute("CREATE INDEX IF NOT EXISTS idx_penis_stats_length ON penis_stats(length DESC)")
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:
"""Список макетов для подсказки."""
parts = []
@ -134,144 +62,12 @@ def _format_delta_ms(delta: timedelta) -> str:
seconds, milliseconds = divmod(rem, 1_000)
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:
"""Логика расписания перекуров."""
now = _normalize_now(now)
if now is None:
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
elif now.tzinfo is None:
now = now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ))
day = now.weekday()
today_schedule = config.SCHEDULE.get(day, [])
@ -298,7 +94,10 @@ def minutes_until_break(now: datetime | None = None) -> str:
def next_break_window(now: datetime | None = None) -> tuple[datetime | None, datetime | None]:
"""Возвращает (начало, конец) ближайшего перекура или (None, None)."""
now = _normalize_now(now)
if now is None:
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
elif now.tzinfo is None:
now = now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ))
day = now.weekday()
today_schedule = config.SCHEDULE.get(day, [])
@ -589,83 +388,6 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
minutes = (total_seconds % 3600) // 60
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) ---
def play_penis(user_id: int, user_name: str | None = None, now: datetime | None = None) -> tuple[bool, str]:
@ -773,161 +495,6 @@ def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str:
lines.append(f"{prefix} {name}{length:.1f} см")
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 ---
async def handle_help(message: Message):
@ -936,24 +503,9 @@ async def handle_help(message: Message):
async def handle_start(message: Message):
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):
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
await message.answer(f"{minutes_until_break(now)}\n{next_lesson_text(now)}")
await message.answer(minutes_until_break(now))
# Ставим будильник на ближайший перекур для этого чата
start_dt, _ = next_break_window(now)
@ -970,9 +522,6 @@ async def handle_penis_cmd(message: Message):
async def handle_top_penis_cmd(message: Message):
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):
size = round(random.uniform(0.1, 35.0), 1)
if size < 5:
@ -993,62 +542,61 @@ async def handle_zvetok_cmd(message: Message):
await message.answer(minutes_until_next_lesson())
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():
return requests.get(
f"{config.MAGNIT_API_BASE_URL.rstrip('/')}/price",
params={
"store_code": product_cfg["store_code"],
"query": product_cfg["query"],
},
timeout=config.MAGNIT_REQUEST_TIMEOUT,
"https://bebrik.xyz/magnit/products",
headers={"X-Token": "sisi"},
timeout=10,
)
label = product_cfg["label"] or product_cfg["query"] or "товар"
try:
resp = await asyncio.to_thread(fetch)
if resp.status_code != 200:
logger.warning("Magnit API returned %s for %s", resp.status_code, product_cfg["query"])
return [f"{label}, ошибка API ({resp.status_code}), -"]
await message.answer("пошел нахуй")
return
data = resp.json()
if not isinstance(data, dict):
return [f"{label}, битый ответ API, -"]
items = _pick_magnit_items(data)
if not items:
return [f"{label}, товар не найден, -"]
lines = []
for item in items:
name = str(item.get("name") or label).strip() or label
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)
if not data:
await message.answer("иди нахуй")
return
text = "смерть в 25:\n\n"
for item in data:
name = item.get("name")
price = item.get("price")
text += f"{name}{price}\n"
await message.answer(text[:4000])
except Exception:
await message.answer("unable to connect x-server гандон")
async def handle_fetch_cmd(message: Message):
await handle_prices_cmd(message)
def fetch():
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):
data = await asyncio.to_thread(get_military_data, "creamy_caprice", 5)
@ -1139,20 +687,6 @@ async def handle_keywords(message: Message):
)
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)
@ -1216,67 +750,6 @@ async def handle_gadanie_cmd(message: Message):
logger.exception("Gadanie failed")
await message.reply("Бля, карты рассыпались, попробуй позже.")
async def handle_matches_cmd(message: Message):
parts = message.text.split(maxsplit=1)
sport_alias = parts[1].strip().lower() if len(parts) > 1 else None
await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
result = await get_formatted_matches(sport_alias)
await message.reply(result, parse_mode=None)
async def handle_bet_cmd(message: Message):
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
parts = message.text.split()
if len(parts) < 4:
await message.reply(
"Формат: /bet <номер> <команда> <ставка>\n"
"Пример: /bet cs 1 NAVI 0.5\n"
"Спорт: cs / dota / football"
)
return
# /bet <спорт> <номер> <команда> <ставка>
if len(parts) >= 5:
sport_alias = parts[1].lower()
try:
index = int(parts[2])
except ValueError:
await message.reply("Номер матча должен быть числом.")
return
team = parts[3]
try:
amount = round(float(parts[4].replace(",", ".")), 1)
except ValueError:
await message.reply("Ставка должна быть числом.")
return
else:
await message.reply("Формат: /bet <спорт> <номер> <команда> <ставка>")
return
match = await get_match_by_index(sport_alias, index)
if not match:
await message.reply("Матч не найден. Посмотри /matches")
return
result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount)
await message.reply(result, parse_mode=None)
async def handle_mybets_cmd(message: Message):
if not message.from_user:
return
result = await asyncio.to_thread(get_user_bets, message.from_user.id)
await message.reply(result, parse_mode=None)
async def _settle_loop(bot: Bot):
while True:
await asyncio.sleep(1800)
try:
notifications = await settle_bets()
for note in notifications:
logger.info(note)
except Exception:
logger.exception("settle_bets failed")
# --- Запуск ---
def main():
@ -1284,20 +757,17 @@ def main():
if not token: raise RuntimeError("BOT_TOKEN не задан!")
init_penis_db()
init_polychaetsi_storage()
bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher()
# Регистрация хендлеров
dp.message.register(handle_start, CommandStart())
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_maket_cmd, Command("maket"))
dp.message.register(handle_size_cmd, Command("size"))
dp.message.register(handle_penis_cmd, Command("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_fetch_cmd, Command("fetch"))
dp.message.register(handle_zvetok_cmd, Command("zvetok"))
@ -1306,9 +776,6 @@ def main():
dp.message.register(handle_ebalnik_cmd, Command("ebalnik"))
dp.message.register(handle_penis_casino_cmd, Command("penis_casino"))
dp.message.register(handle_gadanie_cmd, Command("gadanie"))
dp.message.register(handle_matches_cmd, Command("matches"))
dp.message.register(handle_bet_cmd, Command("bet"))
dp.message.register(handle_mybets_cmd, Command("mybets"))
dp.message.register(handle_gen_mem, Command("gen_mem"))
dp.message.register(handle_keywords, F.text)
@ -1317,24 +784,19 @@ def main():
commands = [
BotCommand(command="start", description="Приветствие"),
BotCommand(command="help", description="Подсказки по командам"),
BotCommand(command="schedule", description="расписание пар"),
BotCommand(command="maket", description="Вписать текст в макет 1"),
BotCommand(command="gen_mem", description="Мем из фото (ответом)"),
BotCommand(command="break", description="Сколько минут до перекура"),
BotCommand(command="size", description="хуй"),
BotCommand(command="penis", description="миниигра 1 раз в 24ч"),
BotCommand(command="top_penis", description="топ по длине"),
BotCommand(command="polychaetsi", description="топ по получается"),
BotCommand(command="prices", description="цены и остатки Магнита"),
BotCommand(command="fetch", description="alias для /prices"),
BotCommand(command="prices", description="цены x-server"),
BotCommand(command="fetch", description="цены (новый источник)"),
BotCommand(command="zvetok", description="Цветянский бля"),
BotCommand(command="talk", description="Побазарить"),
BotCommand(command="ebalnik", description="вкл/выкл автответов"),
BotCommand(command="penis_casino", description="казино на размер"),
BotCommand(command="gadanie", description="гадание на фене"),
BotCommand(command="matches", description="матчи для ставок"),
BotCommand(command="bet", description="поставить см на матч"),
BotCommand(command="mybets", description="мои ставки"),
BotCommand(command="svodka", description="СВО: итоги"),
]
scopes = (
@ -1348,11 +810,6 @@ def main():
logger.warning("Не удалось выставить команды, но бот продолжит работу.")
dp.startup.register(on_startup)
async def start_settle(bot: Bot):
asyncio.create_task(_settle_loop(bot))
dp.startup.register(start_settle)
logger.info("Бот запущен...")
asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View file

@ -1,8 +1,6 @@
aiogram>=3.4.1
pillow>=10.3.0
requests>=2.31.0
aiohttp>=3.9.5
tzdata>=2024.1
beautifulsoup4
lxml
pysqlite3-binary>=0.5.3