Merge pull request 'ПОЛНЫЙ РЕФАКТОРИНГ КОДА. Константы вынесены в config.py, упрощены импорты, вроде бы все работает, а там хуй знает))))' (#9) from SUDOZOVCHIK/bot_tg:main into main
Reviewed-on: #9
This commit is contained in:
commit
65da6a691b
4 changed files with 329 additions and 450 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -20,3 +20,5 @@ db/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
|
|
||||||
AI/models/
|
AI/models/
|
||||||
|
|
||||||
|
main_legacy.py
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ ENV PYTHONUNBUFFERED=1
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY main.py impact.ttf zparser.py ./
|
COPY main.py config.py impact.ttf zparser.py ./
|
||||||
COPY maket ./maket
|
COPY maket ./maket
|
||||||
COPY AI ./AI
|
COPY AI ./AI
|
||||||
CMD ["python", "main.py"]
|
CMD ["python", "main.py"]
|
||||||
|
|
|
||||||
90
config.py
Normal file
90
config.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import timedelta, time
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
# --- Базовые пути ---
|
||||||
|
BASE_DIR = Path(__file__).parent
|
||||||
|
FONT_PATH = BASE_DIR / "impact.ttf"
|
||||||
|
DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow")
|
||||||
|
|
||||||
|
# --- Настройки /penis ---
|
||||||
|
PENIS_DB_PATH = "/db/penis_stats.sqlite3"
|
||||||
|
PENIS_START_LENGTH = 10.0
|
||||||
|
PENIS_MIN_DELTA = 0.1
|
||||||
|
PENIS_MAX_DELTA = 7.0
|
||||||
|
PENIS_COOLDOWN = timedelta(hours=24)
|
||||||
|
PENIS_COOLDOWN_SECONDS = int(PENIS_COOLDOWN.total_seconds())
|
||||||
|
PENIS_TOP_LIMIT = 10
|
||||||
|
|
||||||
|
# --- Конфигурация макетов ---
|
||||||
|
TEMPLATES = {
|
||||||
|
"1": {
|
||||||
|
"path": BASE_DIR / "maket" / "1.png",
|
||||||
|
"anchor": (104, 240),
|
||||||
|
"padding": 20,
|
||||||
|
"label": "Владимир Владимирович Путин самый лучший президент",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Расписание ---
|
||||||
|
def make_time(h: int, m: int) -> time:
|
||||||
|
return time(hour=h, minute=m)
|
||||||
|
|
||||||
|
SCHEDULE = {
|
||||||
|
0: [ # Понедельник
|
||||||
|
(make_time(9, 50), make_time(10, 35), 5),
|
||||||
|
(make_time(10, 40), make_time(11, 25), 30),
|
||||||
|
(make_time(11, 55), make_time(12, 40), 5),
|
||||||
|
(make_time(12, 45), make_time(13, 30), 15),
|
||||||
|
(make_time(13, 45), make_time(14, 30), 5),
|
||||||
|
(make_time(14, 35), make_time(15, 20), 30),
|
||||||
|
(make_time(15, 50), make_time(16, 35), 5),
|
||||||
|
(make_time(16, 40), make_time(17, 25), None),
|
||||||
|
],
|
||||||
|
1: [ # Вторник
|
||||||
|
(make_time(9, 50), make_time(10, 35), 5),
|
||||||
|
(make_time(10, 40), make_time(11, 25), 30),
|
||||||
|
(make_time(11, 55), make_time(12, 40), 5),
|
||||||
|
(make_time(12, 45), make_time(13, 30), 15),
|
||||||
|
(make_time(13, 45), make_time(14, 30), 5),
|
||||||
|
(make_time(14, 35), make_time(15, 20), None),
|
||||||
|
],
|
||||||
|
2: [], # Среда
|
||||||
|
3: [ # Четверг
|
||||||
|
(make_time(8, 0), make_time(8, 45), 5),
|
||||||
|
(make_time(8, 50), make_time(9, 35), 15),
|
||||||
|
(make_time(9, 50), make_time(10, 35), 5),
|
||||||
|
(make_time(10, 40), make_time(11, 25), 30),
|
||||||
|
(make_time(11, 55), make_time(12, 40), 5),
|
||||||
|
(make_time(12, 45), make_time(13, 30), 15),
|
||||||
|
(make_time(13, 45), make_time(14, 30), 5),
|
||||||
|
(make_time(14, 35), make_time(15, 20), 30),
|
||||||
|
(make_time(15, 50), make_time(16, 35), 5),
|
||||||
|
(make_time(16, 40), make_time(17, 25), None),
|
||||||
|
],
|
||||||
|
4: [ # Пятница
|
||||||
|
(make_time(9, 50), make_time(10, 35), 5),
|
||||||
|
(make_time(10, 40), make_time(11, 25), 30),
|
||||||
|
(make_time(11, 55), make_time(12, 40), 5),
|
||||||
|
(make_time(12, 45), make_time(13, 30), 15),
|
||||||
|
(make_time(13, 45), make_time(14, 30), 5),
|
||||||
|
(make_time(14, 35), make_time(15, 20), None),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Остальное ---
|
||||||
|
KEYWORDS = {'крип', 'радиохэд', 'creep', 'radiohead'}
|
||||||
|
|
||||||
|
def get_hint_text(templates_str: str) -> str:
|
||||||
|
return (
|
||||||
|
"Команды:\n"
|
||||||
|
f"• /maket «id» «текст» — доступные: {templates_str}.\n"
|
||||||
|
"• /gen_mem Up:верх Down:низ — отправь с фото.\n"
|
||||||
|
"• /break — узнать время до перекура.\n"
|
||||||
|
"• /penis — миниигра 1 раз в 24 часа.\n"
|
||||||
|
"• /top_penis — топ по длине.\n"
|
||||||
|
"• /talk [текст] — ИИ базарит на фене.\n"
|
||||||
|
"• /prices /fetch — цены.\n"
|
||||||
|
"Подсказка: в группах пиши /команда@username_бота."
|
||||||
|
)
|
||||||
685
main.py
685
main.py
|
|
@ -1,61 +1,36 @@
|
||||||
import asyncio
|
# Системные импорты
|
||||||
from AI.talk_handler import handle_talk
|
import asyncio, logging, os, re, random, sqlite3
|
||||||
import logging
|
from datetime import datetime, time, timedelta
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import requests
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from textwrap import wrap
|
from zoneinfo import ZoneInfo
|
||||||
import random
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
|
# Стороние импорты
|
||||||
|
import requests
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from aiogram import Bot, Dispatcher, F, types
|
from aiogram import Bot, Dispatcher, F, types
|
||||||
from aiogram.enums import ParseMode
|
|
||||||
from aiogram.filters import Command, CommandStart
|
from aiogram.filters import Command, CommandStart
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
from aiogram.types import (
|
from aiogram.types import (
|
||||||
BufferedInputFile,
|
BufferedInputFile, Message, BotCommand, FSInputFile,
|
||||||
Message,
|
BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats
|
||||||
BotCommand,
|
|
||||||
FSInputFile,
|
|
||||||
BotCommandScopeDefault,
|
|
||||||
BotCommandScopeAllPrivateChats,
|
|
||||||
BotCommandScopeAllGroupChats,
|
|
||||||
)
|
)
|
||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
from datetime import datetime, time, timedelta
|
# Локальные импорты
|
||||||
from zoneinfo import ZoneInfo
|
from AI.talk_handler import handle_talk
|
||||||
from zparser import get_military_data
|
from zparser import get_military_data
|
||||||
|
import config
|
||||||
|
|
||||||
# Базовые пути/настройки проекта.
|
logging.basicConfig(level=logging.INFO)
|
||||||
BASE_DIR = Path(__file__).parent
|
logger = logging.getLogger(__name__)
|
||||||
FONT_PATH = BASE_DIR / "impact.ttf"
|
|
||||||
DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow")
|
|
||||||
|
|
||||||
# Данные миниигры /penis храним в SQLite, чтобы не держать их в RAM.
|
|
||||||
PENIS_DB_PATH = "/db/penis_stats.sqlite3"
|
|
||||||
PENIS_START_LENGTH = 10.0
|
|
||||||
PENIS_MIN_DELTA = 0.1
|
|
||||||
PENIS_MAX_DELTA = 7.0
|
|
||||||
PENIS_COOLDOWN = timedelta(hours=24)
|
|
||||||
PENIS_COOLDOWN_SECONDS = int(PENIS_COOLDOWN.total_seconds())
|
|
||||||
PENIS_TOP_LIMIT = 10
|
|
||||||
|
|
||||||
# Конфигурация текстовых макетов /maket.
|
|
||||||
TEMPLATES = {
|
|
||||||
"1": {
|
|
||||||
"path": BASE_DIR / "maket" / "1.png",
|
|
||||||
"anchor": (104, 240),
|
|
||||||
"padding": 20, # right/bottom padding from edges
|
|
||||||
"label": "Владимир Владимирович Путин самый лучший президент",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# --- Инициализация и вспомогательные функции ---
|
||||||
|
|
||||||
def init_penis_db() -> None:
|
def init_penis_db() -> None:
|
||||||
# Инициализация таблицы для миниигры /penis.
|
"""Инициализация БД для миниигры."""
|
||||||
with sqlite3.connect(PENIS_DB_PATH) as conn:
|
Path(config.PENIS_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS penis_stats (
|
CREATE TABLE IF NOT EXISTS penis_stats (
|
||||||
|
|
@ -66,83 +41,26 @@ def init_penis_db() -> None:
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_penis_stats_length ON penis_stats(length DESC)")
|
||||||
"CREATE INDEX IF NOT EXISTS idx_penis_stats_length ON penis_stats(length DESC)"
|
|
||||||
)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def templates_list_text() -> str:
|
def templates_list_text() -> str:
|
||||||
# Собираем человекочитаемый список доступных макетов.
|
"""Список макетов для подсказки."""
|
||||||
parts = []
|
parts = []
|
||||||
for tid in sorted(TEMPLATES.keys()):
|
for tid in sorted(config.TEMPLATES.keys()):
|
||||||
label = TEMPLATES[tid].get("label", "")
|
label = config.TEMPLATES[tid].get("label", "")
|
||||||
if label:
|
parts.append(f"{tid} — {label}" if label else tid)
|
||||||
parts.append(f"{tid} — {label}")
|
|
||||||
else:
|
|
||||||
parts.append(tid)
|
|
||||||
return ", ".join(parts) if parts else "нет макетов"
|
return ", ".join(parts) if parts else "нет макетов"
|
||||||
|
|
||||||
# ---- Schedule & breaks ----
|
|
||||||
|
|
||||||
def make_time(h: int, m: int) -> time:
|
|
||||||
# Короткий хелпер для заполнения расписания.
|
|
||||||
return time(hour=h, minute=m)
|
|
||||||
|
|
||||||
|
|
||||||
# Schedule format: list of (lesson_start, lesson_end, break_minutes_after)
|
|
||||||
SCHEDULE: dict[int, list[tuple[time, time, int | None]]] = {
|
|
||||||
0: [ # Monday
|
|
||||||
(make_time(9, 50), make_time(10, 35), 5),
|
|
||||||
(make_time(10, 40), make_time(11, 25), 30),
|
|
||||||
(make_time(11, 55), make_time(12, 40), 5),
|
|
||||||
(make_time(12, 45), make_time(13, 30), 15),
|
|
||||||
(make_time(13, 45), make_time(14, 30), 5),
|
|
||||||
(make_time(14, 35), make_time(15, 20), 30),
|
|
||||||
(make_time(15, 50), make_time(16, 35), 5),
|
|
||||||
(make_time(16, 40), make_time(17, 25), None),
|
|
||||||
],
|
|
||||||
1: [ # Tuesday
|
|
||||||
(make_time(9, 50), make_time(10, 35), 5),
|
|
||||||
(make_time(10, 40), make_time(11, 25), 30),
|
|
||||||
(make_time(11, 55), make_time(12, 40), 5),
|
|
||||||
(make_time(12, 45), make_time(13, 30), 15),
|
|
||||||
(make_time(13, 45), make_time(14, 30), 5),
|
|
||||||
(make_time(14, 35), make_time(15, 20), None),
|
|
||||||
],
|
|
||||||
2: [], # Wednesday — кайфуем дома
|
|
||||||
3: [ # Thursday
|
|
||||||
(make_time(8, 0), make_time(8, 45), 5),
|
|
||||||
(make_time(8, 50), make_time(9, 35), 15),
|
|
||||||
(make_time(9, 50), make_time(10, 35), 5),
|
|
||||||
(make_time(10, 40), make_time(11, 25), 30),
|
|
||||||
(make_time(11, 55), make_time(12, 40), 5),
|
|
||||||
(make_time(12, 45), make_time(13, 30), 15),
|
|
||||||
(make_time(13, 45), make_time(14, 30), 5),
|
|
||||||
(make_time(14, 35), make_time(15, 20), 30),
|
|
||||||
(make_time(15, 50), make_time(16, 35), 5),
|
|
||||||
(make_time(16, 40), make_time(17, 25), None),
|
|
||||||
],
|
|
||||||
4: [ # Friday
|
|
||||||
(make_time(9, 50), make_time(10, 35), 5),
|
|
||||||
(make_time(10, 40), make_time(11, 25), 30),
|
|
||||||
(make_time(11, 55), make_time(12, 40), 5),
|
|
||||||
(make_time(12, 45), make_time(13, 30), 15),
|
|
||||||
(make_time(13, 45), make_time(14, 30), 5),
|
|
||||||
(make_time(14, 35), make_time(15, 20), None),
|
|
||||||
],
|
|
||||||
5: [], # Saturday — выходной
|
|
||||||
6: [], # Sunday — выходной
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def minutes_until_break(now: datetime | None = None) -> str:
|
def minutes_until_break(now: datetime | None = None) -> str:
|
||||||
# Возвращает статус: до перекура / идёт перекур / пары окончены.
|
"""Логика расписания перекуров."""
|
||||||
if now is None:
|
if now is None:
|
||||||
now = datetime.now(ZoneInfo(DEFAULT_TZ))
|
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
||||||
elif now.tzinfo is None:
|
elif now.tzinfo is None:
|
||||||
now = now.replace(tzinfo=ZoneInfo(DEFAULT_TZ))
|
now = now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ))
|
||||||
|
|
||||||
day = now.weekday()
|
day = now.weekday()
|
||||||
today_schedule = SCHEDULE.get(day, [])
|
today_schedule = config.SCHEDULE.get(day, [])
|
||||||
if not today_schedule:
|
if not today_schedule:
|
||||||
return "Сегодня кайфуем дома, перекур не нужен."
|
return "Сегодня кайфуем дома, перекур не нужен."
|
||||||
|
|
||||||
|
|
@ -153,17 +71,10 @@ def minutes_until_break(now: datetime | None = None) -> str:
|
||||||
start_dt = dt_for(start_t)
|
start_dt = dt_for(start_t)
|
||||||
end_dt = dt_for(end_t)
|
end_dt = dt_for(end_t)
|
||||||
|
|
||||||
# If we're before the lesson
|
if now < start_dt or (start_dt <= now < end_dt):
|
||||||
if now < start_dt:
|
|
||||||
minutes = int((end_dt - now).total_seconds() // 60)
|
minutes = int((end_dt - now).total_seconds() // 60)
|
||||||
return f"{minutes} минут до перекура"
|
return f"{minutes} минут до перекура"
|
||||||
|
|
||||||
# If we're inside the lesson
|
|
||||||
if start_dt <= now < end_dt:
|
|
||||||
minutes = int((end_dt - now).total_seconds() // 60)
|
|
||||||
return f"{minutes} минут до перекура"
|
|
||||||
|
|
||||||
# If we're inside the break after this lesson
|
|
||||||
if br_minutes:
|
if br_minutes:
|
||||||
break_end = end_dt + timedelta(minutes=br_minutes)
|
break_end = end_dt + timedelta(minutes=br_minutes)
|
||||||
if end_dt <= now < break_end:
|
if end_dt <= now < break_end:
|
||||||
|
|
@ -172,78 +83,70 @@ def minutes_until_break(now: datetime | None = None) -> str:
|
||||||
|
|
||||||
return "Пары закончились, перекур только завтра."
|
return "Пары закончились, перекур только завтра."
|
||||||
|
|
||||||
HINT_TEXT = (
|
# --- Работа с изображениями (Макеты и Мемы) ---
|
||||||
# Общая подсказка по командам.
|
|
||||||
"Команды:\n"
|
|
||||||
f"• /maket «id» «текст» — доступные: {templates_list_text()} (1: 737x414, точка 104,240).\n"
|
|
||||||
"• /gen_mem Up:верх Down:низ — отправь с фото (как подпись) или ответом на фото.\n"
|
|
||||||
"• /break — узнать, сколько минут до перекура.\n"
|
|
||||||
"• /penis — миниигра: +/− от 0.1 до 7 см, раз в 24 часа.\n"
|
|
||||||
"• /top_penis — динамический топ по текущей длине.\n"
|
|
||||||
"• /talk [сообщение] — побазарить с ИИ на фене.\n"
|
|
||||||
"• /prices — старый способ цен.\n"
|
|
||||||
"• /fetch — свежие цены с x-server.\n"
|
|
||||||
"Подсказка: в группах команда должна быть слитно с ботом (/gen_mem@username)."
|
|
||||||
)
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont:
|
def load_font(size: int) -> ImageFont.FreeTypeFont:
|
||||||
# Грузим Impact нужного размера.
|
if not config.FONT_PATH.exists():
|
||||||
if not FONT_PATH.exists():
|
raise FileNotFoundError(f"Шрифт не найден: {config.FONT_PATH}")
|
||||||
raise FileNotFoundError("impact.ttf not found. Place the font in the project root.")
|
return ImageFont.truetype(str(config.FONT_PATH), size=size)
|
||||||
return ImageFont.truetype(str(FONT_PATH), size=size)
|
|
||||||
|
|
||||||
|
|
||||||
def wrap_text_to_width(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.ImageDraw, stroke_width: int) -> list[str]:
|
def wrap_text_to_width(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.ImageDraw, stroke_width: int) -> list[str]:
|
||||||
# Переносим текст по словам так, чтобы каждая строка влезла в max_width.
|
|
||||||
words = text.split()
|
words = text.split()
|
||||||
if not words:
|
lines, current = [], []
|
||||||
return []
|
|
||||||
lines: list[str] = []
|
|
||||||
current: list[str] = []
|
|
||||||
for word in words:
|
for word in words:
|
||||||
tentative = " ".join(current + [word]) if current else word
|
tentative = " ".join(current + [word]) if current else word
|
||||||
bbox = draw.textbbox((0, 0), tentative, font=font, stroke_width=stroke_width)
|
bbox = draw.textbbox((0, 0), tentative, font=font, stroke_width=stroke_width)
|
||||||
if bbox[2] - bbox[0] <= max_width:
|
if bbox[2] - bbox[0] <= max_width:
|
||||||
current.append(word)
|
current.append(word)
|
||||||
else:
|
else:
|
||||||
if current:
|
if current: lines.append(" ".join(current))
|
||||||
lines.append(" ".join(current))
|
|
||||||
current = [word]
|
current = [word]
|
||||||
if current:
|
if current: lines.append(" ".join(current))
|
||||||
lines.append(" ".join(current))
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
def parse_up_down(text: str) -> tuple[str, str]:
|
||||||
|
"""Парсит формат /gen_mem Up:текст Down:текст"""
|
||||||
|
up = ""
|
||||||
|
down = ""
|
||||||
|
# Очищаем текст от самой команды
|
||||||
|
text_normalized = re.sub(r'^/\w+\s*', '', text, flags=re.IGNORECASE).replace("\n", " ")
|
||||||
|
|
||||||
|
up_match = re.search(r"(?is)up:(.*?)(?=\s+down:|$)", text_normalized)
|
||||||
|
down_match = re.search(r"(?is)down:(.*)$", text_normalized)
|
||||||
|
|
||||||
|
if up_match:
|
||||||
|
up = up_match.group(1).strip()
|
||||||
|
if down_match:
|
||||||
|
down = down_match.group(1).strip()
|
||||||
|
|
||||||
|
# Если меток нет, по старинке: первое слово вверх, остальное вниз
|
||||||
|
if not up and not down:
|
||||||
|
parts = text_normalized.split(None, 1)
|
||||||
|
if parts:
|
||||||
|
up = parts[0].strip()
|
||||||
|
if len(parts) > 1:
|
||||||
|
down = parts[1].strip()
|
||||||
|
return up, down
|
||||||
|
|
||||||
def fit_text_in_box(text: str, max_width: int, max_height: int, start_size: int = 72, min_size: int = 14):
|
def fit_text_in_box(text: str, max_width: int, max_height: int, start_size: int = 72, min_size: int = 14):
|
||||||
# Подбираем максимальный размер шрифта, который влезет в заданный прямоугольник.
|
draw_dummy = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||||
draw_dummy = ImageDraw.Draw(Image.new("RGB", (max_width, max_height)))
|
|
||||||
for size in range(start_size, min_size - 1, -2):
|
for size in range(start_size, min_size - 1, -2):
|
||||||
font = load_font(size)
|
font = load_font(size)
|
||||||
stroke_width = max(2, size // 12)
|
stroke_width = max(2, size // 12)
|
||||||
lines = wrap_text_to_width(text, font, max_width, draw_dummy, stroke_width)
|
lines = wrap_text_to_width(text, font, max_width, draw_dummy, stroke_width)
|
||||||
if not lines:
|
if not lines: continue
|
||||||
continue
|
|
||||||
line_heights = []
|
line_heights = [draw_dummy.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)[3] -
|
||||||
for ln in lines:
|
draw_dummy.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)[1] for ln in lines]
|
||||||
bbox = draw_dummy.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)
|
|
||||||
line_heights.append(bbox[3] - bbox[1])
|
|
||||||
total_height = sum(line_heights) + max(0, len(lines) - 1) * max(6, size // 6)
|
total_height = sum(line_heights) + max(0, len(lines) - 1) * max(6, size // 6)
|
||||||
if total_height <= max_height:
|
if total_height <= max_height:
|
||||||
return font, lines, stroke_width, max(6, size // 6)
|
return font, lines, stroke_width, max(6, size // 6)
|
||||||
# Если не влезло ни на одном размере, используем минимальный.
|
|
||||||
font = load_font(min_size)
|
font = load_font(min_size)
|
||||||
stroke_width = max(1, min_size // 12)
|
return font, [text], max(1, min_size // 12), max(4, min_size // 6)
|
||||||
lines = wrap_text_to_width(text, font, max_width, draw_dummy, stroke_width)
|
|
||||||
return font, lines or [text], stroke_width, max(4, min_size // 6)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_multiline(draw: ImageDraw.ImageDraw, lines: list[str], position: tuple[int, int], font: ImageFont.FreeTypeFont,
|
def draw_multiline(draw: ImageDraw.ImageDraw, lines: list[str], position: tuple[int, int], font: ImageFont.FreeTypeFont,
|
||||||
stroke_width: int, line_spacing: int, align_center: bool = False, max_width: int | None = None):
|
stroke_width: int, line_spacing: int, align_center: bool = False, max_width: int | None = None):
|
||||||
# Рисуем набор строк с опциональным центрированием по горизонтали.
|
|
||||||
x, y = position
|
x, y = position
|
||||||
for line in lines:
|
for line in lines:
|
||||||
bbox = draw.textbbox((0, 0), line, font=font, stroke_width=stroke_width)
|
bbox = draw.textbbox((0, 0), line, font=font, stroke_width=stroke_width)
|
||||||
|
|
@ -254,33 +157,7 @@ def draw_multiline(draw: ImageDraw.ImageDraw, lines: list[str], position: tuple[
|
||||||
draw.text((draw_x, y), line, font=font, fill="white", stroke_width=stroke_width, stroke_fill="black")
|
draw.text((draw_x, y), line, font=font, fill="white", stroke_width=stroke_width, stroke_fill="black")
|
||||||
y += (bbox[3] - bbox[1]) + line_spacing
|
y += (bbox[3] - bbox[1]) + line_spacing
|
||||||
|
|
||||||
|
|
||||||
def generate_maket(text: str, template_id: str = "1") -> BytesIO:
|
|
||||||
# Генерируем картинку макета с текстом в заранее заданной области.
|
|
||||||
tpl = TEMPLATES.get(template_id)
|
|
||||||
if not tpl:
|
|
||||||
raise FileNotFoundError(f"Template {template_id} is missing")
|
|
||||||
template_path = tpl["path"]
|
|
||||||
anchor = tpl["anchor"]
|
|
||||||
padding = tpl["padding"]
|
|
||||||
if not template_path.exists():
|
|
||||||
raise FileNotFoundError(f"Template file missing: {template_path}")
|
|
||||||
|
|
||||||
with Image.open(template_path).convert("RGB") as img:
|
|
||||||
max_width = img.width - anchor[0] - padding
|
|
||||||
max_height = img.height - anchor[1] - padding
|
|
||||||
font, lines, stroke_width, spacing = fit_text_in_box(text, max_width, max_height, start_size=72)
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
draw_multiline(draw, lines, anchor, font, stroke_width, spacing, align_center=False)
|
|
||||||
|
|
||||||
output = BytesIO()
|
|
||||||
img.save(output, format="PNG")
|
|
||||||
output.seek(0)
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_font_for_width(text: str, image_width: int, max_ratio: float = 0.09, min_size: int = 18):
|
def calculate_font_for_width(text: str, image_width: int, max_ratio: float = 0.09, min_size: int = 18):
|
||||||
# Подбор размера шрифта для верхнего/нижнего блока мема.
|
|
||||||
start_size = max(int(image_width * max_ratio), min_size)
|
start_size = max(int(image_width * max_ratio), min_size)
|
||||||
draw_dummy = ImageDraw.Draw(Image.new("RGB", (image_width, image_width)))
|
draw_dummy = ImageDraw.Draw(Image.new("RGB", (image_width, image_width)))
|
||||||
for size in range(start_size, min_size - 1, -2):
|
for size in range(start_size, min_size - 1, -2):
|
||||||
|
|
@ -293,15 +170,38 @@ def calculate_font_for_width(text: str, image_width: int, max_ratio: float = 0.0
|
||||||
bbox = draw_dummy.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)
|
bbox = draw_dummy.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)
|
||||||
height += bbox[3] - bbox[1]
|
height += bbox[3] - bbox[1]
|
||||||
height += max(0, len(wrapped) - 1) * max(6, size // 6)
|
height += max(0, len(wrapped) - 1) * max(6, size // 6)
|
||||||
if height <= image_width * 0.35: # heuristic to keep blocks from overlapping
|
if height <= image_width * 0.35:
|
||||||
return font, wrapped, stroke_width, max(6, size // 6)
|
return font, wrapped, stroke_width, max(6, size // 6)
|
||||||
|
|
||||||
font = load_font(min_size)
|
font = load_font(min_size)
|
||||||
stroke_width = max(1, min_size // 12)
|
stroke_width = max(1, min_size // 12)
|
||||||
return font, wrap_text_to_width(text, font, int(image_width * 0.95), draw_dummy, stroke_width), stroke_width, max(4, min_size // 6)
|
wrapped = wrap_text_to_width(text, font, int(image_width * 0.95), draw_dummy, stroke_width)
|
||||||
|
return font, wrapped or [text], stroke_width, max(4, min_size // 6)
|
||||||
|
|
||||||
|
def generate_maket(text: str, template_id: str = "1") -> BytesIO:
|
||||||
|
tpl = config.TEMPLATES.get(template_id)
|
||||||
|
if not tpl or not tpl["path"].exists():
|
||||||
|
raise FileNotFoundError(f"Шаблон {template_id} не найден")
|
||||||
|
|
||||||
|
with Image.open(tpl["path"]).convert("RGB") as img:
|
||||||
|
anchor, padding = tpl["anchor"], tpl["padding"]
|
||||||
|
max_w, max_h = img.width - anchor[0] - padding, img.height - anchor[1] - padding
|
||||||
|
|
||||||
|
font, lines, stroke, spacing = fit_text_in_box(text, max_w, max_h)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
y_offset = anchor[1]
|
||||||
|
for line in lines:
|
||||||
|
draw.text((anchor[0], y_offset), line, font=font, fill="white", stroke_width=stroke, stroke_fill="black")
|
||||||
|
bbox = draw.textbbox((0, 0), line, font=font, stroke_width=stroke)
|
||||||
|
y_offset += (bbox[3] - bbox[1]) + spacing
|
||||||
|
|
||||||
|
output = BytesIO()
|
||||||
|
img.save(output, format="PNG")
|
||||||
|
output.seek(0)
|
||||||
|
return output
|
||||||
|
|
||||||
def generate_meme(img: Image.Image, top_text: str, bottom_text: str) -> BytesIO:
|
def generate_meme(img: Image.Image, top_text: str, bottom_text: str) -> BytesIO:
|
||||||
# Рисуем классический мем-текст сверху/снизу и возвращаем JPEG в памяти.
|
|
||||||
img = img.convert("RGB")
|
img = img.convert("RGB")
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
width, height = img.size
|
width, height = img.size
|
||||||
|
|
@ -309,20 +209,35 @@ def generate_meme(img: Image.Image, top_text: str, bottom_text: str) -> BytesIO:
|
||||||
|
|
||||||
if top_text:
|
if top_text:
|
||||||
font, lines, stroke_width, spacing = calculate_font_for_width(top_text, width)
|
font, lines, stroke_width, spacing = calculate_font_for_width(top_text, width)
|
||||||
draw_multiline(draw, lines, (int(width * 0.025), margin_y), font, stroke_width, spacing, align_center=True, max_width=int(width * 0.95))
|
draw_multiline(
|
||||||
|
draw,
|
||||||
|
lines,
|
||||||
|
(int(width * 0.025), margin_y),
|
||||||
|
font,
|
||||||
|
stroke_width,
|
||||||
|
spacing,
|
||||||
|
align_center=True,
|
||||||
|
max_width=int(width * 0.95),
|
||||||
|
)
|
||||||
|
|
||||||
if bottom_text:
|
if bottom_text:
|
||||||
font, lines, stroke_width, spacing = calculate_font_for_width(bottom_text, width)
|
font, lines, stroke_width, spacing = calculate_font_for_width(bottom_text, width)
|
||||||
block_height = 0
|
block_height = 0
|
||||||
line_heights = []
|
|
||||||
for ln in lines:
|
for ln in lines:
|
||||||
bbox = draw.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)
|
bbox = draw.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)
|
||||||
h = bbox[3] - bbox[1]
|
block_height += bbox[3] - bbox[1]
|
||||||
line_heights.append(h)
|
|
||||||
block_height += h
|
|
||||||
block_height += max(0, len(lines) - 1) * spacing
|
block_height += max(0, len(lines) - 1) * spacing
|
||||||
start_y = height - margin_y - block_height
|
start_y = height - margin_y - block_height
|
||||||
draw_multiline(draw, lines, (int(width * 0.025), start_y), font, stroke_width, spacing, align_center=True, max_width=int(width * 0.95))
|
draw_multiline(
|
||||||
|
draw,
|
||||||
|
lines,
|
||||||
|
(int(width * 0.025), start_y),
|
||||||
|
font,
|
||||||
|
stroke_width,
|
||||||
|
spacing,
|
||||||
|
align_center=True,
|
||||||
|
max_width=int(width * 0.95),
|
||||||
|
)
|
||||||
|
|
||||||
output = BytesIO()
|
output = BytesIO()
|
||||||
img.save(output, format="JPEG", quality=95)
|
img.save(output, format="JPEG", quality=95)
|
||||||
|
|
@ -330,89 +245,48 @@ def generate_meme(img: Image.Image, top_text: str, bottom_text: str) -> BytesIO:
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
def parse_up_down(text: str) -> tuple[str, str]:
|
|
||||||
# Парсим формат /gen_mem Up:... Down:...
|
|
||||||
# Если меток нет, используем fallback: первое слово -> up, остальное -> down.
|
|
||||||
up = ""
|
|
||||||
down = ""
|
|
||||||
# Normalize line breaks
|
|
||||||
text_normalized = text.replace("\n", " ")
|
|
||||||
up_match = re.search(r"(?is)up:(.*?)(?=\s+down:|$)", text_normalized)
|
|
||||||
down_match = re.search(r"(?is)down:(.*)$", text_normalized)
|
|
||||||
if up_match:
|
|
||||||
up = up_match.group(1).strip()
|
|
||||||
if down_match:
|
|
||||||
down = down_match.group(1).strip()
|
|
||||||
if not up and not down:
|
|
||||||
parts = text_normalized.split(None, 1)
|
|
||||||
if parts:
|
|
||||||
up = parts[0].strip()
|
|
||||||
if len(parts) > 1:
|
|
||||||
down = parts[1].strip()
|
|
||||||
return up, down
|
|
||||||
|
|
||||||
def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
||||||
# Отдельная "угарная" логика для /zvetok.
|
|
||||||
import random
|
|
||||||
|
|
||||||
if now is None:
|
if now is None:
|
||||||
now = datetime.now(ZoneInfo(DEFAULT_TZ))
|
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
||||||
elif now.tzinfo is None:
|
elif now.tzinfo is None:
|
||||||
now = now.replace(tzinfo=ZoneInfo(DEFAULT_TZ))
|
now = now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ))
|
||||||
|
|
||||||
LESSON_DAYS = [1, 3] # 1 = вторник, 3 = четверг
|
|
||||||
LESSON_START = time(9, 50)
|
|
||||||
LESSON_END = time(11, 25)
|
|
||||||
|
|
||||||
|
lesson_days = [1, 3] # вторник и четверг
|
||||||
|
lesson_start = time(9, 50)
|
||||||
|
lesson_end = time(11, 25)
|
||||||
day = now.weekday()
|
day = now.weekday()
|
||||||
|
|
||||||
def lesson_start_for(day_offset: int) -> datetime:
|
def lesson_start_for(day_offset: int) -> datetime:
|
||||||
target = now + timedelta(days=day_offset)
|
target = now + timedelta(days=day_offset)
|
||||||
return target.replace(
|
return target.replace(hour=lesson_start.hour, minute=lesson_start.minute, second=0, microsecond=0)
|
||||||
hour=LESSON_START.hour,
|
|
||||||
minute=LESSON_START.minute,
|
|
||||||
second=0,
|
|
||||||
microsecond=0
|
|
||||||
)
|
|
||||||
|
|
||||||
def lesson_end_today() -> datetime:
|
def lesson_end_today() -> datetime:
|
||||||
return now.replace(
|
return now.replace(hour=lesson_end.hour, minute=lesson_end.minute, second=0, microsecond=0)
|
||||||
hour=LESSON_END.hour,
|
|
||||||
minute=LESSON_END.minute,
|
|
||||||
second=0,
|
|
||||||
microsecond=0
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---- Проверяем: сейчас внутри пары? ----
|
if day in lesson_days:
|
||||||
if day in LESSON_DAYS:
|
|
||||||
start_today = lesson_start_for(0)
|
start_today = lesson_start_for(0)
|
||||||
end_today = lesson_end_today()
|
end_today = lesson_end_today()
|
||||||
|
|
||||||
if start_today <= now < end_today:
|
if start_today <= now < end_today:
|
||||||
# ищем следующую пару (не сегодняшнюю)
|
|
||||||
for i in range(1, 8):
|
for i in range(1, 8):
|
||||||
next_day = (day + i) % 7
|
next_day = (day + i) % 7
|
||||||
if next_day in LESSON_DAYS:
|
if next_day in lesson_days:
|
||||||
next_dt = lesson_start_for(i)
|
next_dt = lesson_start_for(i)
|
||||||
break
|
break
|
||||||
|
|
||||||
delta = next_dt - now
|
delta = next_dt - now
|
||||||
total_seconds = int(delta.total_seconds())
|
total_seconds = int(delta.total_seconds())
|
||||||
days = total_seconds // 86400
|
days = total_seconds // 86400
|
||||||
hours = (total_seconds % 86400) // 3600
|
hours = (total_seconds % 86400) // 3600
|
||||||
minutes = (total_seconds % 3600) // 60
|
minutes = (total_seconds % 3600) // 60
|
||||||
|
|
||||||
percent = random.randint(0, 100)
|
percent = random.randint(0, 100)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"Сегодня твой мозг сгнил на {percent}%\n"
|
f"Сегодня твой мозг сгнил на {percent}%\n"
|
||||||
f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин"
|
f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- Ищем ближайшую будущую пару ----
|
|
||||||
for i in range(0, 8):
|
for i in range(0, 8):
|
||||||
next_day = (day + i) % 7
|
next_day = (day + i) % 7
|
||||||
if next_day in LESSON_DAYS:
|
if next_day in lesson_days:
|
||||||
candidate = lesson_start_for(i)
|
candidate = lesson_start_for(i)
|
||||||
if candidate > now:
|
if candidate > now:
|
||||||
delta = candidate - now
|
delta = candidate - now
|
||||||
|
|
@ -422,21 +296,20 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
||||||
days = total_seconds // 86400
|
days = total_seconds // 86400
|
||||||
hours = (total_seconds % 86400) // 3600
|
hours = (total_seconds % 86400) // 3600
|
||||||
minutes = (total_seconds % 3600) // 60
|
minutes = (total_seconds % 3600) // 60
|
||||||
|
|
||||||
return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин"
|
return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин"
|
||||||
|
|
||||||
|
# --- Игровая логика (/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]:
|
||||||
# Миниигра /penis: изменение длины и кулдаун 24 часа от последнего броска.
|
|
||||||
if now is None:
|
if now is None:
|
||||||
now = datetime.now(ZoneInfo(DEFAULT_TZ))
|
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
||||||
elif now.tzinfo is None:
|
elif now.tzinfo is None:
|
||||||
now = now.replace(tzinfo=ZoneInfo(DEFAULT_TZ))
|
now = now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ))
|
||||||
now_ts = int(now.timestamp())
|
now_ts = int(now.timestamp())
|
||||||
display_name = user_name.strip() if user_name and user_name.strip() else f"user_{user_id}"
|
display_name = user_name.strip() if user_name and user_name.strip() else f"user_{user_id}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(PENIS_DB_PATH) as conn:
|
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT length, last_used_ts, display_name FROM penis_stats WHERE user_id = ?",
|
"SELECT length, last_used_ts, display_name FROM penis_stats WHERE user_id = ?",
|
||||||
|
|
@ -444,9 +317,8 @@ def play_penis(user_id: int, user_name: str | None = None, now: datetime | None
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
current_length = PENIS_START_LENGTH
|
current_length = config.PENIS_START_LENGTH
|
||||||
last_used_ts = None
|
last_used_ts = None
|
||||||
stored_name = None
|
|
||||||
else:
|
else:
|
||||||
current_length = float(row["length"])
|
current_length = float(row["length"])
|
||||||
last_used_ts = row["last_used_ts"]
|
last_used_ts = row["last_used_ts"]
|
||||||
|
|
@ -455,9 +327,8 @@ def play_penis(user_id: int, user_name: str | None = None, now: datetime | None
|
||||||
display_name = stored_name.strip()
|
display_name = stored_name.strip()
|
||||||
|
|
||||||
if last_used_ts is not None:
|
if last_used_ts is not None:
|
||||||
next_ts = int(last_used_ts) + PENIS_COOLDOWN_SECONDS
|
next_ts = int(last_used_ts) + config.PENIS_COOLDOWN_SECONDS
|
||||||
if now_ts < next_ts:
|
if now_ts < next_ts:
|
||||||
# Даже при кулдауне обновляем имя, чтобы топ показывал актуальный ник.
|
|
||||||
if row is not None and row["display_name"] != display_name:
|
if row is not None and row["display_name"] != display_name:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE penis_stats SET display_name = ? WHERE user_id = ?",
|
"UPDATE penis_stats SET display_name = ? WHERE user_id = ?",
|
||||||
|
|
@ -469,12 +340,10 @@ def play_penis(user_id: int, user_name: str | None = None, now: datetime | None
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин."
|
return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин."
|
||||||
|
|
||||||
delta = round(random.uniform(PENIS_MIN_DELTA, PENIS_MAX_DELTA), 1)
|
delta = round(random.uniform(config.PENIS_MIN_DELTA, config.PENIS_MAX_DELTA), 1)
|
||||||
sign = random.choice([-1, 1])
|
sign = random.choice([-1, 1])
|
||||||
# Не даём длине уйти в отрицательные значения.
|
|
||||||
new_length = round(max(0.1, current_length + sign * delta), 1)
|
new_length = round(max(0.1, current_length + sign * delta), 1)
|
||||||
|
|
||||||
# Для нового пользователя создаём строку, для существующего — обновляем.
|
|
||||||
if row is None:
|
if row is None:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO penis_stats(user_id, display_name, length, last_used_ts) VALUES(?, ?, ?, ?)",
|
"INSERT INTO penis_stats(user_id, display_name, length, last_used_ts) VALUES(?, ?, ?, ?)",
|
||||||
|
|
@ -498,20 +367,16 @@ def play_penis(user_id: int, user_name: str | None = None, now: datetime | None
|
||||||
"Следующая попытка через 24 часа.",
|
"Следующая попытка через 24 часа.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def format_penis_user_name(user: types.User) -> str:
|
def format_penis_user_name(user: types.User) -> str:
|
||||||
# Красиво подписываем пользователя для топа.
|
|
||||||
if user.username:
|
if user.username:
|
||||||
return f"@{user.username}"
|
return f"@{user.username}"
|
||||||
if user.full_name:
|
if user.full_name:
|
||||||
return user.full_name
|
return user.full_name
|
||||||
return f"user_{user.id}"
|
return f"user_{user.id}"
|
||||||
|
|
||||||
|
def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str:
|
||||||
def build_penis_top(limit: int = PENIS_TOP_LIMIT) -> str:
|
|
||||||
# Формируем динамический топ по текущим значениям /penis.
|
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(PENIS_DB_PATH) as conn:
|
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -539,90 +404,30 @@ def build_penis_top(limit: int = PENIS_TOP_LIMIT) -> str:
|
||||||
prefix = medals.get(index, f"{index}.")
|
prefix = medals.get(index, f"{index}.")
|
||||||
lines.append(f"{prefix} {name} — {length:.1f} см")
|
lines.append(f"{prefix} {name} — {length:.1f} см")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
async def handle_start(message: Message):
|
|
||||||
# Базовый /start.
|
|
||||||
await message.answer("Соси")
|
|
||||||
|
|
||||||
|
# --- Хендлеры aiogram ---
|
||||||
|
|
||||||
async def handle_help(message: Message):
|
async def handle_help(message: Message):
|
||||||
# Вывод подсказки по возможностям бота.
|
await message.answer(config.get_hint_text(templates_list_text()))
|
||||||
await message.answer(HINT_TEXT)
|
|
||||||
|
|
||||||
async def handle_break(message: Message):
|
async def handle_start(message: Message):
|
||||||
# Команда расписания перекура.
|
await message.answer("Соси")
|
||||||
|
|
||||||
|
async def handle_break_cmd(message: Message):
|
||||||
await message.answer(minutes_until_break())
|
await message.answer(minutes_until_break())
|
||||||
|
|
||||||
|
async def handle_penis_cmd(message: Message):
|
||||||
async def handle_maket(message: Message):
|
if not message.from_user:
|
||||||
# Генерация текста в подготовленном шаблоне /maket.
|
await message.answer("Команда доступна только пользователям.")
|
||||||
parts = message.text.split(maxsplit=2)
|
|
||||||
if len(parts) == 1:
|
|
||||||
await message.reply(f"Доступные макеты: {templates_list_text()}\nФормат: /maket «id» «текст»")
|
|
||||||
return
|
return
|
||||||
if len(parts) < 3:
|
display_name = format_penis_user_name(message.from_user)
|
||||||
await message.reply(f"Формат: /maket «id» «текст»\nДоступные макеты: {templates_list_text()}\n\n" + HINT_TEXT)
|
await message.answer(play_penis(message.from_user.id, display_name)[1])
|
||||||
return
|
|
||||||
template_id = parts[1]
|
|
||||||
if template_id not in TEMPLATES:
|
|
||||||
await message.reply(f"Такого макета нет. Доступные: {templates_list_text()}")
|
|
||||||
return
|
|
||||||
text = parts[2].strip()
|
|
||||||
if not text:
|
|
||||||
await message.reply("Нужен текст после номера макета.")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
output = await asyncio.to_thread(generate_maket, text, template_id)
|
|
||||||
except Exception as exc: # pragma: no cover - user feedback path
|
|
||||||
logger.exception("Failed to generate maket")
|
|
||||||
await message.reply(f"Не получилось: {exc}")
|
|
||||||
return
|
|
||||||
await message.reply_photo(BufferedInputFile(output.getvalue(), filename="maket.png"))
|
|
||||||
|
|
||||||
|
async def handle_top_penis_cmd(message: Message):
|
||||||
|
await message.answer(build_penis_top())
|
||||||
|
|
||||||
async def handle_gen_mem(message: Message, bot: Bot):
|
async def handle_size_cmd(message: Message):
|
||||||
# Мем можно сделать из фото в этом же сообщении или из reply на фото.
|
|
||||||
photo_message = None
|
|
||||||
if message.photo:
|
|
||||||
photo_message = message
|
|
||||||
elif message.reply_to_message and message.reply_to_message.photo:
|
|
||||||
photo_message = message.reply_to_message
|
|
||||||
|
|
||||||
if not photo_message:
|
|
||||||
await message.reply("Еблан, /gen_mem Up: Down:")
|
|
||||||
return
|
|
||||||
|
|
||||||
top_text, bottom_text = parse_up_down(message.text or "")
|
|
||||||
if not top_text and not bottom_text:
|
|
||||||
await message.reply("Еблан, /gen_mem Up: Down:")
|
|
||||||
return
|
|
||||||
|
|
||||||
photo = photo_message.photo[-1]
|
|
||||||
try:
|
|
||||||
# Берём самое большое фото Telegram и скачиваем в память.
|
|
||||||
file = await bot.get_file(photo.file_id)
|
|
||||||
buf = BytesIO()
|
|
||||||
await bot.download_file(file.file_path, destination=buf)
|
|
||||||
buf.seek(0)
|
|
||||||
img = Image.open(buf)
|
|
||||||
except Exception as exc: # pragma: no cover
|
|
||||||
logger.exception("Failed to download photo")
|
|
||||||
await message.reply(f"Не могу скачать фото: {exc}")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
output = await asyncio.to_thread(generate_meme, img, top_text, bottom_text)
|
|
||||||
except Exception as exc: # pragma: no cover
|
|
||||||
logger.exception("Failed to render meme")
|
|
||||||
await message.reply(f"Не получилось сделать мем: {exc}")
|
|
||||||
return
|
|
||||||
|
|
||||||
await message.reply_photo(BufferedInputFile(output.getvalue(), filename="meme.jpg"))
|
|
||||||
|
|
||||||
async def handle_size(message: Message):
|
|
||||||
# Простая рандом-команда /size.
|
|
||||||
size = round(random.uniform(0.1, 35.0), 1)
|
size = round(random.uniform(0.1, 35.0), 1)
|
||||||
|
|
||||||
if size < 5:
|
if size < 5:
|
||||||
status = "СВОи"
|
status = "СВОи"
|
||||||
elif size < 10:
|
elif size < 10:
|
||||||
|
|
@ -635,107 +440,53 @@ async def handle_size(message: Message):
|
||||||
status = "Пиздабол"
|
status = "Пиздабол"
|
||||||
else:
|
else:
|
||||||
status = "Говноед"
|
status = "Говноед"
|
||||||
|
await message.answer(f"📏 Твой хуек: {size} см\nТы: {status}")
|
||||||
|
|
||||||
await message.answer(
|
async def handle_zvetok_cmd(message: Message):
|
||||||
f"📏 Твой хуек: {size} см\n"
|
await message.answer(minutes_until_next_lesson())
|
||||||
f"Ты: {status}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
async def handle_prices_cmd(message: Message):
|
||||||
async def handle_penis(message: Message):
|
|
||||||
# Хендлер миниигры /penis.
|
|
||||||
if not message.from_user:
|
|
||||||
await message.answer("Команда доступна только пользователям.")
|
|
||||||
return
|
|
||||||
|
|
||||||
display_name = format_penis_user_name(message.from_user)
|
|
||||||
_, result_text = play_penis(message.from_user.id, display_name)
|
|
||||||
await message.answer(result_text)
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_top_penis(message: Message):
|
|
||||||
# Хендлер топа по значениям /penis.
|
|
||||||
await message.answer(build_penis_top())
|
|
||||||
|
|
||||||
|
|
||||||
# Ключевые слова, на которые бот кидает трек Creep.
|
|
||||||
KEYWORDS = {'крип', 'радиохэд', 'creep', 'radiohead'}
|
|
||||||
# I'M CREEP
|
|
||||||
async def handle_keywords(message: types.Message):
|
|
||||||
# Не реагируем на команды, только на обычный текст.
|
|
||||||
|
|
||||||
if message.text and message.text.startswith('/'):
|
|
||||||
return
|
|
||||||
|
|
||||||
text = message.text.lower()
|
|
||||||
|
|
||||||
if any(re.search(rf'\b{word}\b', text) for word in KEYWORDS):
|
|
||||||
audio_path = BASE_DIR / "maket" / "Radiohead - Creep.mp3"
|
|
||||||
|
|
||||||
if audio_path.exists():
|
|
||||||
await message.answer_audio(
|
|
||||||
audio=FSInputFile(audio_path),
|
|
||||||
caption="I'm a creep, I'm a weirdo..."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(f"Файл не найден по пути: {audio_path}")
|
|
||||||
|
|
||||||
async def handle_prices(message: Message):
|
|
||||||
# Старый источник цен (/prices).
|
|
||||||
def fetch():
|
def fetch():
|
||||||
return requests.get(
|
return requests.get(
|
||||||
"https://bebrik.xyz/magnit/products",
|
"https://bebrik.xyz/magnit/products",
|
||||||
headers={"X-Token": "sisi"},
|
headers={"X-Token": "sisi"},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
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("пошел нахуй")
|
await message.answer("пошел нахуй")
|
||||||
return
|
return
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
await message.answer("иди нахуй")
|
await message.answer("иди нахуй")
|
||||||
return
|
return
|
||||||
|
|
||||||
text = "смерть в 25:\n\n"
|
text = "смерть в 25:\n\n"
|
||||||
for item in data:
|
for item in data:
|
||||||
name = item.get("name")
|
name = item.get("name")
|
||||||
price = item.get("price")
|
price = item.get("price")
|
||||||
text += f"{name} — {price} ₽\n"
|
text += f"{name} — {price} ₽\n"
|
||||||
|
|
||||||
await message.answer(text[:4000])
|
await message.answer(text[:4000])
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
await message.answer("unable to connect x-server гандон")
|
await message.answer("unable to connect x-server гандон")
|
||||||
|
|
||||||
|
async def handle_fetch_cmd(message: Message):
|
||||||
async def handle_fetch(message: Message):
|
|
||||||
# Новый источник цен (/fetch).
|
|
||||||
def fetch():
|
def fetch():
|
||||||
return requests.get(
|
return requests.get(
|
||||||
"https://mirror.porno4free.ru/magnit/magnit/prices/all",
|
"https://mirror.porno4free.ru/magnit/magnit/prices/all",
|
||||||
headers={"x-token": "secret_token"},
|
headers={"x-token": "secret_token"},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
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.reply("пошел нахуй")
|
await message.reply("пошел нахуй")
|
||||||
return
|
return
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
if not isinstance(data, dict) or not data:
|
if not isinstance(data, dict) or not data:
|
||||||
await message.reply("иди нахуй")
|
await message.reply("иди нахуй")
|
||||||
return
|
return
|
||||||
|
|
||||||
lines = ["смерть в 25:"]
|
lines = ["смерть в 25:"]
|
||||||
# сортируем категории для стабильного вывода
|
|
||||||
for group in sorted(data.keys()):
|
for group in sorted(data.keys()):
|
||||||
items = data.get(group)
|
items = data.get(group)
|
||||||
lines.append(f"\n{group}:")
|
lines.append(f"\n{group}:")
|
||||||
|
|
@ -747,21 +498,12 @@ async def handle_fetch(message: Message):
|
||||||
if not name or price is None:
|
if not name or price is None:
|
||||||
continue
|
continue
|
||||||
lines.append(f"{name} — {price} ₽")
|
lines.append(f"{name} — {price} ₽")
|
||||||
text = "\n".join(lines)
|
await message.answer("\n".join(lines)[:4000])
|
||||||
await message.answer(text[:4000])
|
|
||||||
except Exception:
|
except Exception:
|
||||||
await message.answer("unable to connect x-server гандон")
|
await message.answer("unable to connect x-server гандон")
|
||||||
|
|
||||||
async def handle_zvetok(message: Message):
|
async def handle_svodka_cmd(message: Message):
|
||||||
# Команда с отдельной "мемной" логикой таймера.
|
data = await asyncio.to_thread(get_military_data, "creamy_caprice", 5)
|
||||||
result = minutes_until_next_lesson()
|
|
||||||
await message.answer(result)
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_svodka(message: Message):
|
|
||||||
channel = "creamy_caprice"
|
|
||||||
data = get_military_data(channel, depth=5)
|
|
||||||
|
|
||||||
if data["status"] == "success":
|
if data["status"] == "success":
|
||||||
response = (
|
response = (
|
||||||
f"в период с {data['dates']} хохлы в среднем проебывали по {data['pace']} км² в сутки, "
|
f"в период с {data['dates']} хохлы в среднем проебывали по {data['pace']} км² в сутки, "
|
||||||
|
|
@ -771,21 +513,93 @@ async def handle_svodka(message: Message):
|
||||||
else:
|
else:
|
||||||
await message.answer(f"Хохлы ответят за это...: {data['message']}")
|
await message.answer(f"Хохлы ответят за это...: {data['message']}")
|
||||||
|
|
||||||
|
async def handle_maket_cmd(message: Message):
|
||||||
|
parts = message.text.split(maxsplit=2)
|
||||||
|
if len(parts) == 1:
|
||||||
|
await message.reply(f"Доступные макеты: {templates_list_text()}\nФормат: /maket «id» «текст»")
|
||||||
|
return
|
||||||
|
if len(parts) < 3:
|
||||||
|
await message.reply(
|
||||||
|
f"Формат: /maket «id» «текст»\nДоступные макеты: {templates_list_text()}\n\n"
|
||||||
|
+ config.get_hint_text(templates_list_text())
|
||||||
|
)
|
||||||
|
return
|
||||||
|
template_id = parts[1]
|
||||||
|
if template_id not in config.TEMPLATES:
|
||||||
|
await message.reply(f"Такого макета нет. Доступные: {templates_list_text()}")
|
||||||
|
return
|
||||||
|
text = parts[2].strip()
|
||||||
|
if not text:
|
||||||
|
await message.reply("Нужен текст после номера макета.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
output = await asyncio.to_thread(generate_maket, text, template_id)
|
||||||
|
await message.reply_photo(BufferedInputFile(output.getvalue(), filename="maket.png"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to generate maket")
|
||||||
|
await message.reply(f"Не получилось: {exc}")
|
||||||
|
|
||||||
|
async def handle_gen_mem(message: Message, bot: Bot):
|
||||||
|
photo_msg = message if message.photo else (message.reply_to_message if message.reply_to_message and message.reply_to_message.photo else None)
|
||||||
|
if not photo_msg:
|
||||||
|
await message.reply("Еблан, /gen_mem Up: Down:")
|
||||||
|
return
|
||||||
|
full_text = message.text or ""
|
||||||
|
top_text, bottom_text = parse_up_down(full_text)
|
||||||
|
if not top_text and not bottom_text:
|
||||||
|
await message.reply("Еблан, /gen_mem Up: Down:")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
file = await bot.get_file(photo_msg.photo[-1].file_id)
|
||||||
|
buf = BytesIO()
|
||||||
|
await bot.download_file(file.file_path, destination=buf)
|
||||||
|
buf.seek(0)
|
||||||
|
img = Image.open(buf)
|
||||||
|
output = await asyncio.to_thread(generate_meme, img, top_text, bottom_text)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to render meme")
|
||||||
|
await message.reply(f"Не получилось сделать мем: {exc}")
|
||||||
|
return
|
||||||
|
await message.reply_photo(BufferedInputFile(output.getvalue(), filename="meme.jpg"))
|
||||||
|
|
||||||
|
async def handle_keywords(message: Message):
|
||||||
|
if not message.text or message.text.startswith('/'):
|
||||||
|
return
|
||||||
|
if any(re.search(rf'\b{word}\b', message.text.lower()) for word in config.KEYWORDS):
|
||||||
|
audio_path = config.BASE_DIR / "maket" / "Radiohead - Creep.mp3"
|
||||||
|
if audio_path.exists():
|
||||||
|
await message.answer_audio(
|
||||||
|
audio=FSInputFile(audio_path),
|
||||||
|
caption="I'm a creep, I'm a weirdo...",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Запуск ---
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# Точка входа: создаём бота, регистрируем команды и запускаем polling.
|
|
||||||
token = os.getenv("BOT_TOKEN")
|
token = os.getenv("BOT_TOKEN")
|
||||||
if not token:
|
if not token: raise RuntimeError("BOT_TOKEN не задан!")
|
||||||
raise RuntimeError("BOT_TOKEN env var is required")
|
|
||||||
|
|
||||||
# Готовим SQLite-таблицу для /penis и /top_penis.
|
|
||||||
init_penis_db()
|
init_penis_db()
|
||||||
|
|
||||||
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_help, Command("help"))
|
||||||
|
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_prices_cmd, Command("prices"))
|
||||||
|
dp.message.register(handle_fetch_cmd, Command("fetch"))
|
||||||
|
dp.message.register(handle_zvetok_cmd, Command("zvetok"))
|
||||||
|
dp.message.register(handle_talk, Command("talk"))
|
||||||
|
dp.message.register(handle_svodka_cmd, Command("svodka"))
|
||||||
|
dp.message.register(handle_gen_mem, Command("gen_mem"))
|
||||||
|
dp.message.register(handle_keywords, F.text)
|
||||||
|
|
||||||
async def on_startup(bot: Bot):
|
async def on_startup(bot: Bot):
|
||||||
# Выставляем меню команд в интерфейсе Telegram.
|
|
||||||
try:
|
try:
|
||||||
commands = [
|
commands = [
|
||||||
BotCommand(command="start", description="Приветствие"),
|
BotCommand(command="start", description="Приветствие"),
|
||||||
|
|
@ -802,7 +616,6 @@ def main():
|
||||||
BotCommand(command="talk", description="Побазарить"),
|
BotCommand(command="talk", description="Побазарить"),
|
||||||
BotCommand(command="svodka", description="СВО: итоги"),
|
BotCommand(command="svodka", description="СВО: итоги"),
|
||||||
]
|
]
|
||||||
# Явно задаём команды для default/private/group, чтобы меню через "/" появлялось стабильно.
|
|
||||||
scopes = (
|
scopes = (
|
||||||
BotCommandScopeDefault(),
|
BotCommandScopeDefault(),
|
||||||
BotCommandScopeAllPrivateChats(),
|
BotCommandScopeAllPrivateChats(),
|
||||||
|
|
@ -810,38 +623,12 @@ def main():
|
||||||
)
|
)
|
||||||
for scope in scopes:
|
for scope in scopes:
|
||||||
await bot.set_my_commands(commands, scope=scope)
|
await bot.set_my_commands(commands, scope=scope)
|
||||||
except Exception: # noqa: WPS440
|
except Exception:
|
||||||
logger.warning("Не удалось выставить команды, но бот продолжит работу.")
|
logger.warning("Не удалось выставить команды, но бот продолжит работу.")
|
||||||
|
|
||||||
dp.startup.register(on_startup)
|
dp.startup.register(on_startup)
|
||||||
|
logger.info("Бот запущен...")
|
||||||
# Регистрация команд-обработчиков.
|
|
||||||
dp.message.register(handle_start, CommandStart())
|
|
||||||
dp.message.register(handle_help, Command("help"))
|
|
||||||
dp.message.register(handle_break, Command("break"))
|
|
||||||
dp.message.register(handle_maket, Command("maket"))
|
|
||||||
dp.message.register(handle_size, Command("size"))
|
|
||||||
dp.message.register(handle_penis, Command("penis"))
|
|
||||||
dp.message.register(handle_top_penis, Command("top_penis"))
|
|
||||||
dp.message.register(handle_prices, Command("prices"))
|
|
||||||
dp.message.register(handle_fetch, Command("fetch"))
|
|
||||||
dp.message.register(handle_zvetok, Command("zvetok"))
|
|
||||||
dp.message.register(handle_talk, Command("talk"))
|
|
||||||
dp.message.register(handle_svodka, Command("svodka"))
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_gen_mem_cmd(message: Message, bot: Bot):
|
|
||||||
# Обёртка под сигнатуру aiogram для /gen_mem.
|
|
||||||
await handle_gen_mem(message, bot)
|
|
||||||
|
|
||||||
dp.message.register(handle_gen_mem_cmd, Command("gen_mem"))
|
|
||||||
|
|
||||||
# Текстовые авто-реакции (без команд).
|
|
||||||
dp.message.register(handle_keywords, F.text)
|
|
||||||
|
|
||||||
logger.info("Bot is starting...")
|
|
||||||
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()))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue