diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 4cc66d8..6b2bef8 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -7,8 +7,10 @@ from aiogram.types import Message logger = logging.getLogger(__name__) +# Адрес OpenAI-совместимого API у llama.cpp сервера. LLAMA_API_URL = os.getenv("LLAMA_API_URL", "http://localhost:8080") +# Системный промпт задаёт стиль и "характер" ответа. SYSTEM_PROMPT = ( "Ты — чёткий пацан с района. Базаришь строго на фене — блатной жаргон, " "тюремный сленг, дворовый язык. Используй слова: кент, братуха, мусора, " @@ -23,6 +25,7 @@ SYSTEM_PROMPT = ( async def _generate_response(user_text: str) -> str: + # Формируем запрос к /v1/chat/completions и возвращаем текст первого ответа. url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions" payload = { "messages": [ @@ -40,6 +43,7 @@ async def _generate_response(user_text: str) -> str: async def handle_talk(message: Message): + # Обработчик /talk: валидация ввода, запрос к LLM, отправка ответа пользователю. parts = message.text.split(maxsplit=1) if len(parts) < 2 or not parts[1].strip(): await message.reply("Ты чё, кент? Напиши /talk [текст], побазарим.") @@ -47,6 +51,7 @@ async def handle_talk(message: Message): user_text = parts[1].strip() + # Показываем "печатает..." пока ждём ответ модели. await message.bot.send_chat_action(chat_id=message.chat.id, action="typing") try: @@ -55,5 +60,6 @@ async def handle_talk(message: Message): response = "Братуха, чёт базар не клеится, попробуй ещё раз." await message.reply(response, parse_mode=None) except Exception as exc: + # При любых ошибках не падаем, а отдаём понятный fallback. logger.exception("Talk generation failed") await message.reply("Бля, кент, чёт движок заглох. Попробуй позже.") diff --git a/main.py b/main.py index 5fcfa72..d591d18 100644 --- a/main.py +++ b/main.py @@ -18,9 +18,21 @@ from PIL import Image, ImageDraw, ImageFont from datetime import datetime, time, timedelta from zoneinfo import ZoneInfo +# Базовые пути/настройки проекта. BASE_DIR = Path(__file__).parent FONT_PATH = BASE_DIR / "impact.ttf" DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow") + +# In-memory состояние миниигры /penis. +# Важно: после перезапуска процесса всё обнуляется. +PENIS_STATE: dict[int, dict[str, object]] = {} +PENIS_START_LENGTH = 10.0 +PENIS_MIN_DELTA = 0.1 +PENIS_MAX_DELTA = 7.0 +PENIS_COOLDOWN = timedelta(hours=24) +PENIS_TOP_LIMIT = 10 + +# Конфигурация текстовых макетов /maket. TEMPLATES = { "1": { "path": BASE_DIR / "maket" / "1.png", @@ -31,6 +43,7 @@ TEMPLATES = { } def templates_list_text() -> str: + # Собираем человекочитаемый список доступных макетов. parts = [] for tid in sorted(TEMPLATES.keys()): label = TEMPLATES[tid].get("label", "") @@ -43,6 +56,7 @@ def templates_list_text() -> str: # ---- Schedule & breaks ---- def make_time(h: int, m: int) -> time: + # Короткий хелпер для заполнения расписания. return time(hour=h, minute=m) @@ -93,6 +107,7 @@ SCHEDULE: dict[int, list[tuple[time, time, int | None]]] = { def minutes_until_break(now: datetime | None = None) -> str: + # Возвращает статус: до перекура / идёт перекур / пары окончены. if now is None: now = datetime.now(ZoneInfo(DEFAULT_TZ)) elif now.tzinfo is None: @@ -129,10 +144,13 @@ 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" @@ -144,12 +162,14 @@ 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) 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 [] @@ -170,6 +190,7 @@ def wrap_text_to_width(text: str, font: ImageFont.FreeTypeFont, max_width: int, 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))) for size in range(start_size, min_size - 1, -2): font = load_font(size) @@ -184,7 +205,7 @@ def fit_text_in_box(text: str, max_width: int, max_height: int, start_size: int 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) - # Fallback to minimal size + # Если не влезло ни на одном размере, используем минимальный. 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) @@ -193,6 +214,7 @@ def fit_text_in_box(text: str, max_width: int, max_height: int, start_size: int 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) @@ -205,6 +227,7 @@ def draw_multiline(draw: ImageDraw.ImageDraw, lines: list[str], position: tuple[ 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") @@ -228,6 +251,7 @@ def generate_maket(text: str, template_id: str = "1") -> BytesIO: 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): @@ -248,6 +272,7 @@ def calculate_font_for_width(text: str, image_width: int, max_ratio: float = 0.0 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 @@ -277,6 +302,8 @@ def generate_meme(img: Image.Image, top_text: str, bottom_text: str) -> BytesIO: def parse_up_down(text: str) -> tuple[str, str]: + # Парсим формат /gen_mem Up:... Down:... + # Если меток нет, используем fallback: первое слово -> up, остальное -> down. up = "" down = "" # Normalize line breaks @@ -296,6 +323,7 @@ def parse_up_down(text: str) -> tuple[str, str]: return up, down def minutes_until_next_lesson(now: datetime | None = None) -> str: + # Отдельная "угарная" логика для /zvetok. import random if now is None: @@ -369,18 +397,99 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str: return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин" +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)) + elif now.tzinfo is None: + now = now.replace(tzinfo=ZoneInfo(DEFAULT_TZ)) + + state = PENIS_STATE.get(user_id) + if state is None: + state = {"length": PENIS_START_LENGTH, "last_used_at": None} + PENIS_STATE[user_id] = state + + # Обновляем отображаемое имя для топа. + if user_name: + state["display_name"] = user_name + + last_used_at = state.get("last_used_at") + if isinstance(last_used_at, datetime): + next_time = last_used_at + PENIS_COOLDOWN + if now < next_time: + # Если рано, считаем оставшееся время до следующей попытки. + left = next_time - now + total_seconds = int(left.total_seconds()) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин." + + current_length = float(state.get("length", PENIS_START_LENGTH)) + delta = round(random.uniform(PENIS_MIN_DELTA, PENIS_MAX_DELTA), 1) + sign = random.choice([-1, 1]) + # Не даём длине уйти в отрицательные значения. + new_length = round(max(0.1, current_length + sign * delta), 1) + + state["length"] = new_length + state["last_used_at"] = now + + sign_text = "+" if sign > 0 else "-" + return ( + True, + f"📏 Изменение: {sign_text}{delta:.1f} см\n" + f"Текущая длина: {new_length:.1f} см\n" + "Следующая попытка через 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. + rows: list[tuple[float, str]] = [] + for user_id, state in PENIS_STATE.items(): + length = state.get("length") + if not isinstance(length, (int, float)): + continue + display_name = state.get("display_name") + if not isinstance(display_name, str) or not display_name.strip(): + display_name = f"user_{user_id}" + rows.append((float(length), display_name.strip())) + + if not rows: + return "🏆 Топ пуст. Сначала пусть кто-то крутнет /penis." + + rows.sort(key=lambda item: item[0], reverse=True) + medals = {1: "🥇", 2: "🥈", 3: "🥉"} + lines = ["🏆 Топ по длине:"] + for index, (length, name) in enumerate(rows[:limit], start=1): + 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("Соси") async def handle_help(message: Message): + # Вывод подсказки по возможностям бота. await message.answer(HINT_TEXT) async def handle_break(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» «текст»") @@ -406,6 +515,7 @@ async def handle_maket(message: Message): async def handle_gen_mem(message: Message, bot: Bot): + # Мем можно сделать из фото в этом же сообщении или из reply на фото. photo_message = None if message.photo: photo_message = message @@ -423,6 +533,7 @@ async def handle_gen_mem(message: Message, bot: Bot): 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) @@ -443,6 +554,7 @@ async def handle_gen_mem(message: Message, bot: Bot): 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) if size < 5: @@ -463,9 +575,28 @@ async def handle_size(message: Message): f"Ты: {status}" ) + +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 @@ -484,6 +615,7 @@ async def handle_keywords(message: types.Message): logger.warning(f"Файл не найден по пути: {audio_path}") async def handle_prices(message: Message): + # Старый источник цен (/prices). def fetch(): return requests.get( "https://bebrik.xyz/magnit/products", @@ -517,6 +649,7 @@ async def handle_prices(message: Message): async def handle_fetch(message: Message): + # Новый источник цен (/fetch). def fetch(): return requests.get( "https://mirror.porno4free.ru/magnit/magnit/prices/all", @@ -554,11 +687,13 @@ async def handle_fetch(message: Message): await message.answer("unable to connect x-server гандон") async def handle_zvetok(message: Message): + # Команда с отдельной "мемной" логикой таймера. result = minutes_until_next_lesson() await message.answer(result) def main(): + # Точка входа: создаём бота, регистрируем команды и запускаем polling. token = os.getenv("BOT_TOKEN") if not token: raise RuntimeError("BOT_TOKEN env var is required") @@ -567,6 +702,7 @@ def main(): dp = Dispatcher() async def on_startup(bot: Bot): + # Выставляем меню команд в интерфейсе Telegram. try: await bot.set_my_commands( [ @@ -576,6 +712,8 @@ def main(): BotCommand(command="gen_mem", description="Мем из фото (ответом)"), BotCommand(command="break", description="Сколько минут до перекура"), BotCommand(command="size", description="хуй"), + BotCommand(command="penis", description="миниигра 1 раз в 24ч"), + BotCommand(command="top_penis", description="топ по длине"), BotCommand(command="prices", description="цены x-server"), BotCommand(command="fetch", description="цены (новый источник)"), BotCommand(command="zvetok", description="Цветянский бля"), @@ -587,23 +725,28 @@ def main(): 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")) - + 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...")