diff --git a/.gitignore b/.gitignore index a9a50d6..09a5540 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ db/ .mypy_cache/ AI/models/ + +main_legacy.py diff --git a/Dockerfile b/Dockerfile index 4a22520..daf8ac8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ENV PYTHONUNBUFFERED=1 WORKDIR /app COPY 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 AI ./AI CMD ["python", "main.py"] diff --git a/config.py b/config.py new file mode 100644 index 0000000..ea3e81b --- /dev/null +++ b/config.py @@ -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_бота." + ) \ No newline at end of file diff --git a/main.py b/main.py index e25ef15..729f9a6 100644 --- a/main.py +++ b/main.py @@ -1,61 +1,36 @@ -import asyncio -from AI.talk_handler import handle_talk -import logging -import os -import re -import requests +# Системные импорты +import asyncio, logging, os, re, random, sqlite3 +from datetime import datetime, time, timedelta from io import BytesIO from pathlib import Path -from textwrap import wrap -import random -import sqlite3 +from zoneinfo import ZoneInfo +# Стороние импорты +import requests +from PIL import Image, ImageDraw, ImageFont from aiogram import Bot, Dispatcher, F, types -from aiogram.enums import ParseMode from aiogram.filters import Command, CommandStart +from aiogram.enums import ParseMode from aiogram.types import ( - BufferedInputFile, - Message, - BotCommand, - FSInputFile, - BotCommandScopeDefault, - BotCommandScopeAllPrivateChats, - BotCommandScopeAllGroupChats, + BufferedInputFile, Message, BotCommand, FSInputFile, + BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats ) 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 +import config -# Базовые пути/настройки проекта. -BASE_DIR = Path(__file__).parent -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": "Владимир Владимирович Путин самый лучший президент", - }, -} +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +# --- Инициализация и вспомогательные функции --- 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( """ CREATE TABLE IF NOT EXISTS penis_stats ( @@ -66,83 +41,26 @@ def init_penis_db() -> None: ) """ ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_penis_stats_length ON penis_stats(length DESC)" - ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_penis_stats_length ON penis_stats(length DESC)") conn.commit() def templates_list_text() -> str: - # Собираем человекочитаемый список доступных макетов. + """Список макетов для подсказки.""" parts = [] - for tid in sorted(TEMPLATES.keys()): - label = TEMPLATES[tid].get("label", "") - if label: - parts.append(f"{tid} — {label}") - else: - parts.append(tid) + for tid in sorted(config.TEMPLATES.keys()): + label = config.TEMPLATES[tid].get("label", "") + parts.append(f"{tid} — {label}" if label else tid) 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: - # Возвращает статус: до перекура / идёт перекур / пары окончены. + """Логика расписания перекуров.""" if now is None: - now = datetime.now(ZoneInfo(DEFAULT_TZ)) + now = datetime.now(ZoneInfo(config.DEFAULT_TZ)) elif now.tzinfo is None: - now = now.replace(tzinfo=ZoneInfo(DEFAULT_TZ)) + now = now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ)) + day = now.weekday() - today_schedule = SCHEDULE.get(day, []) + today_schedule = config.SCHEDULE.get(day, []) if not today_schedule: return "Сегодня кайфуем дома, перекур не нужен." @@ -153,17 +71,10 @@ def minutes_until_break(now: datetime | None = None) -> str: start_dt = dt_for(start_t) end_dt = dt_for(end_t) - # If we're before the lesson - if now < start_dt: + if now < start_dt or (start_dt <= now < end_dt): minutes = int((end_dt - now).total_seconds() // 60) 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: break_end = end_dt + timedelta(minutes=br_minutes) if end_dt <= now < break_end: @@ -172,78 +83,70 @@ def minutes_until_break(now: datetime | None = None) -> str: 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: - # Грузим Impact нужного размера. - if not FONT_PATH.exists(): - raise FileNotFoundError("impact.ttf not found. Place the font in the project root.") - return ImageFont.truetype(str(FONT_PATH), size=size) - + if not config.FONT_PATH.exists(): + raise FileNotFoundError(f"Шрифт не найден: {config.FONT_PATH}") + return ImageFont.truetype(str(config.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]: - # Переносим текст по словам так, чтобы каждая строка влезла в max_width. words = text.split() - if not words: - return [] - lines: list[str] = [] - current: list[str] = [] + lines, current = [], [] for word in words: tentative = " ".join(current + [word]) if current else word bbox = draw.textbbox((0, 0), tentative, font=font, stroke_width=stroke_width) if bbox[2] - bbox[0] <= max_width: current.append(word) else: - if current: - lines.append(" ".join(current)) + if current: lines.append(" ".join(current)) current = [word] - if current: - lines.append(" ".join(current)) + if current: lines.append(" ".join(current)) 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): - # Подбираем максимальный размер шрифта, который влезет в заданный прямоугольник. - draw_dummy = ImageDraw.Draw(Image.new("RGB", (max_width, max_height))) + draw_dummy = ImageDraw.Draw(Image.new("RGB", (1, 1))) for size in range(start_size, min_size - 1, -2): font = load_font(size) stroke_width = max(2, size // 12) lines = wrap_text_to_width(text, font, max_width, draw_dummy, stroke_width) - if not lines: - continue - line_heights = [] - for ln in lines: - bbox = draw_dummy.textbbox((0, 0), ln, font=font, stroke_width=stroke_width) - line_heights.append(bbox[3] - bbox[1]) + if not lines: continue + + line_heights = [draw_dummy.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)[3] - + draw_dummy.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)[1] for ln in lines] total_height = sum(line_heights) + max(0, len(lines) - 1) * max(6, size // 6) if total_height <= max_height: return font, lines, stroke_width, max(6, size // 6) - # Если не влезло ни на одном размере, используем минимальный. + font = load_font(min_size) - stroke_width = max(1, min_size // 12) - 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) - + return font, [text], max(1, min_size // 12), max(4, min_size // 6) 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): - # Рисуем набор строк с опциональным центрированием по горизонтали. x, y = position for line in lines: 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") 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): - # Подбор размера шрифта для верхнего/нижнего блока мема. start_size = max(int(image_width * max_ratio), min_size) draw_dummy = ImageDraw.Draw(Image.new("RGB", (image_width, image_width))) 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) height += bbox[3] - bbox[1] 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) + font = load_font(min_size) 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: - # Рисуем классический мем-текст сверху/снизу и возвращаем JPEG в памяти. img = img.convert("RGB") draw = ImageDraw.Draw(img) width, height = img.size @@ -309,20 +209,35 @@ def generate_meme(img: Image.Image, top_text: str, bottom_text: str) -> BytesIO: if top_text: 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: font, lines, stroke_width, spacing = calculate_font_for_width(bottom_text, width) block_height = 0 - line_heights = [] for ln in lines: bbox = draw.textbbox((0, 0), ln, font=font, stroke_width=stroke_width) - h = bbox[3] - bbox[1] - line_heights.append(h) - block_height += h + block_height += bbox[3] - bbox[1] block_height += max(0, len(lines) - 1) * spacing 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() 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 -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: - # Отдельная "угарная" логика для /zvetok. - import random - if now is None: - now = datetime.now(ZoneInfo(DEFAULT_TZ)) + now = datetime.now(ZoneInfo(config.DEFAULT_TZ)) elif now.tzinfo is None: - now = now.replace(tzinfo=ZoneInfo(DEFAULT_TZ)) - - LESSON_DAYS = [1, 3] # 1 = вторник, 3 = четверг - LESSON_START = time(9, 50) - LESSON_END = time(11, 25) + now = now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ)) + lesson_days = [1, 3] # вторник и четверг + lesson_start = time(9, 50) + lesson_end = time(11, 25) day = now.weekday() def lesson_start_for(day_offset: int) -> datetime: target = now + timedelta(days=day_offset) - return target.replace( - hour=LESSON_START.hour, - minute=LESSON_START.minute, - second=0, - microsecond=0 - ) + return target.replace(hour=lesson_start.hour, minute=lesson_start.minute, second=0, microsecond=0) def lesson_end_today() -> datetime: - return now.replace( - hour=LESSON_END.hour, - minute=LESSON_END.minute, - second=0, - microsecond=0 - ) + return now.replace(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) end_today = lesson_end_today() - if start_today <= now < end_today: - # ищем следующую пару (не сегодняшнюю) for i in range(1, 8): next_day = (day + i) % 7 - if next_day in LESSON_DAYS: + if next_day in lesson_days: next_dt = lesson_start_for(i) break - delta = next_dt - now total_seconds = int(delta.total_seconds()) days = total_seconds // 86400 hours = (total_seconds % 86400) // 3600 minutes = (total_seconds % 3600) // 60 - percent = random.randint(0, 100) - return ( f"Сегодня твой мозг сгнил на {percent}%\n" f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин" ) - # ---- Ищем ближайшую будущую пару ---- for i in range(0, 8): next_day = (day + i) % 7 - if next_day in LESSON_DAYS: + if next_day in lesson_days: candidate = lesson_start_for(i) if candidate > now: delta = candidate - now @@ -422,21 +296,20 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str: days = total_seconds // 86400 hours = (total_seconds % 86400) // 3600 minutes = (total_seconds % 3600) // 60 - return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин" +# --- Игровая логика (/penis) --- def play_penis(user_id: int, user_name: str | None = None, now: datetime | None = None) -> tuple[bool, str]: - # Миниигра /penis: изменение длины и кулдаун 24 часа от последнего броска. if now is None: - now = datetime.now(ZoneInfo(DEFAULT_TZ)) + now = datetime.now(ZoneInfo(config.DEFAULT_TZ)) 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()) display_name = user_name.strip() if user_name and user_name.strip() else f"user_{user_id}" try: - with sqlite3.connect(PENIS_DB_PATH) as conn: + with sqlite3.connect(config.PENIS_DB_PATH) as conn: conn.row_factory = sqlite3.Row row = conn.execute( "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() if row is None: - current_length = PENIS_START_LENGTH + current_length = config.PENIS_START_LENGTH last_used_ts = None - stored_name = None else: current_length = float(row["length"]) 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() 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 row is not None and row["display_name"] != display_name: conn.execute( "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() 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]) - # Не даём длине уйти в отрицательные значения. new_length = round(max(0.1, current_length + sign * delta), 1) - # Для нового пользователя создаём строку, для существующего — обновляем. if row is None: conn.execute( "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 часа.", ) - def format_penis_user_name(user: types.User) -> str: - # Красиво подписываем пользователя для топа. if user.username: return f"@{user.username}" if user.full_name: return user.full_name return f"user_{user.id}" - -def build_penis_top(limit: int = PENIS_TOP_LIMIT) -> str: - # Формируем динамический топ по текущим значениям /penis. +def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str: try: - with sqlite3.connect(PENIS_DB_PATH) as conn: + with sqlite3.connect(config.PENIS_DB_PATH) as conn: conn.row_factory = sqlite3.Row rows = conn.execute( """ @@ -539,90 +404,30 @@ def build_penis_top(limit: int = PENIS_TOP_LIMIT) -> str: prefix = medals.get(index, f"{index}.") lines.append(f"{prefix} {name} — {length:.1f} см") return "\n".join(lines) - -async def handle_start(message: Message): - # Базовый /start. - await message.answer("Соси") +# --- Хендлеры aiogram --- async def handle_help(message: Message): - # Вывод подсказки по возможностям бота. - await message.answer(HINT_TEXT) + await message.answer(config.get_hint_text(templates_list_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()) - -async def handle_maket(message: Message): - # Генерация текста в подготовленном шаблоне /maket. - parts = message.text.split(maxsplit=2) - if len(parts) == 1: - await message.reply(f"Доступные макеты: {templates_list_text()}\nФормат: /maket «id» «текст»") +async def handle_penis_cmd(message: Message): + if not message.from_user: + await message.answer("Команда доступна только пользователям.") return - if len(parts) < 3: - await message.reply(f"Формат: /maket «id» «текст»\nДоступные макеты: {templates_list_text()}\n\n" + HINT_TEXT) - 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")) + display_name = format_penis_user_name(message.from_user) + await message.answer(play_penis(message.from_user.id, display_name)[1]) +async def handle_top_penis_cmd(message: Message): + await message.answer(build_penis_top()) -async def handle_gen_mem(message: Message, bot: Bot): - # Мем можно сделать из фото в этом же сообщении или из 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. +async def handle_size_cmd(message: Message): size = round(random.uniform(0.1, 35.0), 1) - if size < 5: status = "СВОи" elif size < 10: @@ -635,107 +440,53 @@ async def handle_size(message: Message): status = "Пиздабол" else: status = "Говноед" + await message.answer(f"📏 Твой хуек: {size} см\nТы: {status}") - await message.answer( - f"📏 Твой хуек: {size} см\n" - f"Ты: {status}" - ) +async def handle_zvetok_cmd(message: Message): + await message.answer(minutes_until_next_lesson()) - -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). +async def handle_prices_cmd(message: Message): def fetch(): return requests.get( "https://bebrik.xyz/magnit/products", headers={"X-Token": "sisi"}, timeout=10, ) - try: resp = await asyncio.to_thread(fetch) - if resp.status_code != 200: await message.answer("пошел нахуй") return - data = resp.json() - 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(message: Message): - # Новый источник цен (/fetch). +async def handle_fetch_cmd(message: 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}:") @@ -747,21 +498,12 @@ async def handle_fetch(message: Message): if not name or price is None: continue lines.append(f"{name} — {price} ₽") - text = "\n".join(lines) - await message.answer(text[:4000]) + await message.answer("\n".join(lines)[:4000]) except Exception: await message.answer("unable to connect x-server гандон") -async def handle_zvetok(message: Message): - # Команда с отдельной "мемной" логикой таймера. - 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) - +async def handle_svodka_cmd(message: Message): + data = await asyncio.to_thread(get_military_data, "creamy_caprice", 5) if data["status"] == "success": response = ( f"в период с {data['dates']} хохлы в среднем проебывали по {data['pace']} км² в сутки, " @@ -771,21 +513,93 @@ async def handle_svodka(message: Message): else: 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(): - # Точка входа: создаём бота, регистрируем команды и запускаем polling. token = os.getenv("BOT_TOKEN") - if not token: - raise RuntimeError("BOT_TOKEN env var is required") + if not token: raise RuntimeError("BOT_TOKEN не задан!") - # Готовим SQLite-таблицу для /penis и /top_penis. init_penis_db() - 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_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): - # Выставляем меню команд в интерфейсе Telegram. try: commands = [ BotCommand(command="start", description="Приветствие"), @@ -802,7 +616,6 @@ def main(): BotCommand(command="talk", description="Побазарить"), BotCommand(command="svodka", description="СВО: итоги"), ] - # Явно задаём команды для default/private/group, чтобы меню через "/" появлялось стабильно. scopes = ( BotCommandScopeDefault(), BotCommandScopeAllPrivateChats(), @@ -810,38 +623,12 @@ def main(): ) for scope in scopes: await bot.set_my_commands(commands, scope=scope) - except Exception: # noqa: WPS440 + except Exception: logger.warning("Не удалось выставить команды, но бот продолжит работу.") dp.startup.register(on_startup) - - # Регистрация команд-обработчиков. - 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...") + logger.info("Бот запущен...") asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())) - if __name__ == "__main__": main()