diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 7355db7..2758c97 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -20,6 +20,11 @@ SYSTEM_PROMPT = ( "Стиль: 1–3 предложения; если список — максимум 3 пункта. " "Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n" + "ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и " + "«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или " + "перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме " + "(факт, совет, действие) без поддакиваний.\n\n" + "Лексика (мешай, но не лепи всё подряд одним полотном): " "кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, " "чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, " @@ -57,7 +62,11 @@ SYSTEM_PROMPT = ( ) # Размер контекста — количество сообщений на чат -_CONTEXT_SIZE = 100 +# Сколько сообщений держим в БД. Нужно для быстрой очистки. +_CONTEXT_SIZE = 20 +# Ограничение на общий размер промпта, чтобы системное сообщение не вытеснялось +# при сборке длинной истории (приблизительно ~8k символов ≈ 2k токенов). +_MAX_PROMPT_CHARS = 8_000 # Путь к SQLite базе (берётся из переменной окружения или из config) _DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH", "/db/chat_history.sqlite3") @@ -114,9 +123,18 @@ def _get_history(chat_id: int) -> list[dict]: def _build_messages(chat_id: int, system_prompt: str, current_text: str, current_name: str) -> list: """Собирает список messages для LLM из истории чата.""" messages = [{"role": "system", "content": system_prompt}] - for entry in _get_history(chat_id): + + # Берём историю с конца, пока не превысим бюджет по символам. + history = [] + used = len(system_prompt) + for entry in reversed(_get_history(chat_id)): content = f"{entry['name']}: {entry['text']}" if entry["role"] == "user" else entry["text"] - messages.append({"role": entry["role"], "content": content}) + if used + len(content) > _MAX_PROMPT_CHARS: + break + history.append({"role": entry["role"], "content": content}) + used += len(content) + + messages.extend(reversed(history)) messages.append({"role": "user", "content": f"{current_name}: {current_text}"}) return messages @@ -127,7 +145,7 @@ async def _generate_response(chat_id: int, user_text: str, user_name: str) -> st msgs = await asyncio.to_thread(_build_messages, chat_id, SYSTEM_PROMPT, user_text, user_name) payload = { "messages": msgs, - "max_tokens": 500, + "max_tokens": 200, "temperature": 0.8, "top_p": 0.9, } @@ -171,6 +189,39 @@ AUTOREPLY_SYSTEM_PROMPT = ( "Прокомментируй сообщение коротко (1-2 предложения), дерзко, на фене. " "Можешь пошутить, подколоть, согласиться или послать. " "НЕ начинай с обращения. Не используй формальный язык." + "Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — " + "дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. " + "Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. " + "Стиль: 1–3 предложения; если список — максимум 3 пункта. " + "Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n" + + "ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и " + "«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или " + "перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме " + "(факт, совет, действие) без поддакиваний.\n\n" + + "Лексика (мешай, но не лепи всё подряд одним полотном): " + "кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, " + "чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, " + "жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n" + + "Фирменные приёмы речи:\n" + "— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n" + "— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n" + "— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n" + "— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, " + "как будто проговариваешь по слогам. Примеры: " + "над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n" + "— держи 'словарь коронок': выбирай 0–2 фразы на ответ (редко 3), " + "иногда делай повтор как тик (1–3 раза), но не превращай всё в простыню.\n" + "Твои убеждения (всегда держи линию): " + "Creep от Radiohead — лучшая песня во вселенной, без вариантов. " + "Линукс — лучшая ОС во вселенной, остальное для фраеров. " + "Вселенную надо переписать на Rust, это не обсуждается. " + "Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n" + + "Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», " + "а дальше пересказывай своими словами, без длинных дословных цитат." ) diff --git a/config.py b/config.py index 183f263..26bcebb 100644 --- a/config.py +++ b/config.py @@ -26,6 +26,12 @@ TEMPLATES = { "padding": 20, "label": "Владимир Владимирович Путин самый лучший президент", }, + "2": { + "path": BASE_DIR / "maket" / "2.png", + "anchor": (40, 40), + "box_size": (582, 346), + "label": "Макет 2", + }, } # --- Расписание --- @@ -91,6 +97,7 @@ def get_hint_text(templates_str: str) -> str: "• /penis — миниигра 1 раз в 24 часа.\n" "• /top_penis — топ по длине.\n" "• /talk [текст] — ИИ базарит на фене.\n" + "• /ebalnik — включить/выключить автоответы в чате.\n" "• /prices /fetch — цены.\n" "Подсказка: в группах пиши /команда@username_бота." - ) \ No newline at end of file + ) diff --git a/main.py b/main.py index 4364fa7..3ed8c4f 100644 --- a/main.py +++ b/main.py @@ -52,6 +52,14 @@ def templates_list_text() -> str: parts.append(f"{tid} — {label}" if label else tid) return ", ".join(parts) if parts else "нет макетов" +def _format_delta_ms(delta: timedelta) -> str: + """HH:MM:SS.mmm, не допускаем отрицательных значений.""" + total_ms = int(max(delta.total_seconds(), 0) * 1000) + hours, rem = divmod(total_ms, 3_600_000) + minutes, rem = divmod(rem, 60_000) + seconds, milliseconds = divmod(rem, 1_000) + return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{milliseconds:03d}" + def minutes_until_break(now: datetime | None = None) -> str: """Логика расписания перекуров.""" if now is None: @@ -72,17 +80,93 @@ def minutes_until_break(now: datetime | None = None) -> str: end_dt = dt_for(end_t) if now < start_dt or (start_dt <= now < end_dt): - minutes = int((end_dt - now).total_seconds() // 60) - return f"{minutes} минут до перекура" + return f"{_format_delta_ms(end_dt - now)} до перекура" if br_minutes: break_end = end_dt + timedelta(minutes=br_minutes) if end_dt <= now < break_end: - remaining = int((break_end - now).total_seconds() // 60) - return f"Идёт перекур, осталось {remaining} минут" + return f"Идёт перекур, осталось {_format_delta_ms(break_end - now)}" return "Пары закончились, перекур только завтра." + +def next_break_window(now: datetime | None = None) -> tuple[datetime | None, datetime | None]: + """Возвращает (начало, конец) ближайшего перекура или (None, None).""" + if now is None: + now = datetime.now(ZoneInfo(config.DEFAULT_TZ)) + elif now.tzinfo is None: + now = now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ)) + + day = now.weekday() + today_schedule = config.SCHEDULE.get(day, []) + if not today_schedule: + return None, None + + def dt_for(t: time) -> datetime: + return now.replace(hour=t.hour, minute=t.minute, second=0, microsecond=0) + + for start_t, end_t, br_minutes in today_schedule: + if not br_minutes: + continue + end_dt = dt_for(end_t) + br_start = end_dt + br_end = end_dt + timedelta(minutes=br_minutes) + # Ближайший будущий или текущий перекур + if now < br_end: + return br_start, br_end + + return None, None + + +# --- Авто-напоминание о перекуре --- +_break_tasks: dict[int, tuple[datetime, asyncio.Task]] = {} +_break_notified: dict[int, datetime] = {} + + +async def _send_break_photo(chat_id: int, bot: Bot, start_dt: datetime) -> None: + last = _break_notified.get(chat_id) + if last and last == start_dt: + return # Уже кидали уведомление для этого слота + + photo_path = config.BASE_DIR / "maket" / "vape.jpg" + if not photo_path.exists(): + logger.warning("vape.jpg not found, skip break photo") + return + + try: + await bot.send_photo(chat_id=chat_id, photo=FSInputFile(photo_path), caption="идем курить") + _break_notified[chat_id] = start_dt + except Exception: + logger.exception("Failed to send break photo") + + +async def _break_notification(chat_id: int, bot: Bot, start_dt: datetime) -> None: + try: + now = datetime.now(start_dt.tzinfo) + delay = (start_dt - now).total_seconds() + if delay > 0: + await asyncio.sleep(delay) + await _send_break_photo(chat_id, bot, start_dt) + except asyncio.CancelledError: + pass + finally: + stored = _break_tasks.get(chat_id) + if stored and stored[0] == start_dt: + _break_tasks.pop(chat_id, None) + + +def schedule_break_notification(chat_id: int, bot: Bot, start_dt: datetime) -> None: + existing = _break_tasks.get(chat_id) + if existing: + scheduled_start, task = existing + if scheduled_start == start_dt and not task.done(): + return # Уже ждём этот же слот + if not task.done(): + task.cancel() + + task = asyncio.create_task(_break_notification(chat_id, bot, start_dt)) + _break_tasks[chat_id] = (start_dt, task) + # --- Работа с изображениями (Макеты и Мемы) --- def load_font(size: int) -> ImageFont.FreeTypeFont: @@ -184,8 +268,12 @@ def generate_maket(text: str, template_id: str = "1") -> BytesIO: 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 + anchor = tpl["anchor"] + if "box_size" in tpl: + max_w, max_h = tpl["box_size"] + else: + padding = tpl.get("padding", 0) + 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) @@ -414,7 +502,13 @@ async def handle_start(message: Message): await message.answer("Соси") async def handle_break_cmd(message: Message): - await message.answer(minutes_until_break()) + now = datetime.now(ZoneInfo(config.DEFAULT_TZ)) + await message.answer(minutes_until_break(now)) + + # Ставим будильник на ближайший перекур для этого чата + start_dt, _ = next_break_window(now) + if start_dt: + schedule_break_notification(message.chat.id, message.bot, start_dt) async def handle_penis_cmd(message: Message): if not message.from_user: @@ -513,6 +607,15 @@ async def handle_svodka_cmd(message: Message): else: await message.answer(f"Хохлы ответят за это...: {data['message']}") +async def handle_ebalnik_cmd(message: Message): + chat_id = message.chat.id + if chat_id in _autoreply_disabled_chats: + _autoreply_disabled_chats.remove(chat_id) + await message.reply("Автоответы вернул, бот снова влезает в чат.") + else: + _autoreply_disabled_chats.add(chat_id) + await message.reply("Автоответы срезал, бот теперь молчит в этом чате.") + async def handle_maket_cmd(message: Message): parts = message.text.split(maxsplit=2) if len(parts) == 1: @@ -563,6 +666,7 @@ async def handle_gen_mem(message: Message, bot: Bot): await message.reply_photo(BufferedInputFile(output.getvalue(), filename="meme.jpg")) _last_autoreply_ts = 0 +_autoreply_disabled_chats: set[int] = set() async def handle_keywords(message: Message): global _last_autoreply_ts @@ -584,6 +688,9 @@ async def handle_keywords(message: Message): # Записываем каждое обычное сообщение в историю чата await asyncio.to_thread(push_message, chat_id, "user", user_name, message.text) + if chat_id in _autoreply_disabled_chats: + return + import time as _time now = _time.time() triggered = any(trigger in text_lower for trigger in config.AUTOREPLY_TRIGGERS) @@ -627,6 +734,7 @@ def main(): 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_ebalnik_cmd, Command("ebalnik")) dp.message.register(handle_gen_mem, Command("gen_mem")) dp.message.register(handle_keywords, F.text) @@ -645,6 +753,7 @@ def main(): BotCommand(command="fetch", description="цены (новый источник)"), BotCommand(command="zvetok", description="Цветянский бля"), BotCommand(command="talk", description="Побазарить"), + BotCommand(command="ebalnik", description="вкл/выкл автответов"), BotCommand(command="svodka", description="СВО: итоги"), ] scopes = ( diff --git a/maket/2.png b/maket/2.png new file mode 100644 index 0000000..550a1e5 Binary files /dev/null and b/maket/2.png differ diff --git a/maket/vape.jpg b/maket/vape.jpg new file mode 100644 index 0000000..2ccf97c Binary files /dev/null and b/maket/vape.jpg differ