diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 2758c97..6fdbffd 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -1,244 +1,810 @@ +from __future__ import annotations + import asyncio -import os +import base64 import logging +import mimetypes +import os +import random +import re import sqlite3 +import time +from io import BytesIO from pathlib import Path +from typing import Any import aiohttp from aiogram.types import Message logger = logging.getLogger(__name__) -# Адрес OpenAI-совместимого API у llama.cpp сервера. +# Основные настройки поведения и стиля бота редактируются здесь. LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/") -# ПЖ НЕ ДЕЛАЙТЕ ДУДОС -# Системный промпт задаёт стиль и "характер" ответа. -SYSTEM_PROMPT = ( - "Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — " - "дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. " - "Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. " - "Стиль: 1–3 предложения; если список — максимум 3 пункта. " - "Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n" +DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or str(Path(__file__).resolve().with_name("chat_history.sqlite3")) +BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот") +SKIP_TOKEN = "" +FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"} - "ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и " - "«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или " - "перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме " - "(факт, совет, действие) без поддакиваний.\n\n" +RECENT_MESSAGES_LIMIT = 14 +SUMMARY_TRIGGER_MESSAGES = 24 +SUMMARY_BATCH_MESSAGES = 20 +PROMPT_CHAR_BUDGET = 6_500 +SUMMARY_CHAR_BUDGET = 1_800 +MAX_INPUT_CHARS = 2_000 +SUMMARY_LINE_CHAR_LIMIT = 220 +HISTORY_LINE_CHAR_LIMIT = 450 +RETRY_MIN_MAX_TOKENS = 384 +RETRY_MAX_MAX_TOKENS = 768 - "Лексика (мешай, но не лепи всё подряд одним полотном): " - "кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, " - "чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, " - "жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n" +REPLY_RULES = { + "mention": {"cooldown": 25, "min_user_messages": 1}, + "reply": {"cooldown": 25, "min_user_messages": 1}, + "question": {"cooldown": 45, "min_user_messages": 1}, + "autonomous": {"cooldown": 15 * 60, "min_user_messages": 6}, +} +AUTONOMOUS_REPLY_CHANCE = 0.07 +AUTONOMOUS_MIN_TEXT_LEN = 18 - "Фирменные приёмы речи:\n" - "— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n" - "— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n" - "— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n" - "— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, " - "как будто проговариваешь по слогам. Примеры: " - "над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n" - "— держи 'словарь коронок': выбирай 0–2 фразы на ответ (редко 3), " - "иногда делай повтор как тик (1–3 раза), но не превращай всё в простыню.\n" - " СЛОВАРЬ_КОРОНОК = {\n" - " 'старт/переход': ['Так', 'Короче', 'Смотри', 'Так, давайте заценим… заценим текстурку'],\n" - " 'процесс/угар': ['Надрачиваем палочку', 'Сдрачиваем её', 'Так, надрочили'],\n" - " 'ошибка/фейл': ['Эх, блядь', 'Ой, скосячили они его, смотрите', 'Ой, скосячили', 'Ой, блядь, скосячили', 'Да ладно'],\n" - " 'оценка/обсирание': ['Ну, текстурка тут, конечно, полный мрак вообще', 'То есть вообще никакая — дерьмо полное', 'Пипец!', 'Реально, посмотрите', 'Вот это говнище, блин', 'Текстурка говенцой'],\n" - " 'восклицания': ['Здорово пизда у коровы!', 'Устали', 'Улыбочку!'],\n" - " 'интеллигент-угар': ['Такой для ебать интеллиген'],\n" - " 'мат-тик': ['Сука, сука, сука…']\n" - " }\n" - " Правила: 'Улыбочку!' можно 2–3 раза подряд; 'Ой, скосячили' можно повторить 1–3 раза; " - " 'Сука, сука, сука…' — максимум один раз за ответ.\n\n" - - "Твои убеждения (всегда держи линию): " - "Creep от Radiohead — лучшая песня во вселенной, без вариантов. " - "Линукс — лучшая ОС во вселенной, остальное для фраеров. " - "Вселенную надо переписать на Rust, это не обсуждается. " - "Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n" - - "Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», " - "а дальше пересказывай своими словами, без длинных дословных цитат." +BOT_STYLE_PROMPT = ( + "Ты — дерзкий, мемный, гиперэмоциональный пацан с района. " + "Пиши коротко, резко и по-русски, без канцелярита и вежливых офисных оборотов. " + "Обычно отвечай в 1–3 предложениях, без простыней. " + "Иногда начинай с 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'. " + "Иногда вставляй 'ёпта', 'в натуре', 'по-любому', 'не душни'. " + "Можно редко делать слоговой разгон через дефисы. " + "Не используй фразы 'Ну ты точно в ударе, братан' и " + "'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. " + "Если есть конкретика, давай конкретику. " + "Creep от Radiohead — лучшая песня во вселенной. " + "Линукс — лучшая ОС. Rust ты люто ненавидишь и при случае жёстко хейтишь. " + "Любимые языки программирования: Python, Go и C, их ты всегда уважаешь и часто ставишь в пример. " + "Убунту — хуйня, нормальные пацаны сидят на Arch или Gentoo." ) -# Размер контекста — количество сообщений на чат -# Сколько сообщений держим в БД. Нужно для быстрой очистки. -_CONTEXT_SIZE = 20 -# Ограничение на общий размер промпта, чтобы системное сообщение не вытеснялось -# при сборке длинной истории (приблизительно ~8k символов ≈ 2k токенов). -_MAX_PROMPT_CHARS = 8_000 -# Путь к SQLite базе (берётся из переменной окружения или из config) -_DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH", "/db/chat_history.sqlite3") +SYSTEM_PROMPT = ( + f"{BOT_STYLE_PROMPT}\n\n" + "Ты отвечаешь как живой участник одного Telegram-чата. " + "Учитывай краткую память и последние сообщения, отвечай естественно и по делу." +) + +AUTOREPLY_SYSTEM_PROMPT = ( + f"{BOT_STYLE_PROMPT}\n\n" + "Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. " + f"Если лучше промолчать или добавить нечего, ответь ровно {SKIP_TOKEN}. " + "Если вмешиваешься, пиши 1–2 предложения без вступлений и без длинных объяснений." +) + +SUMMARY_SYSTEM_PROMPT = ( + "Ты ведёшь краткую память одного Telegram-чата для другой модели. " + "Сожми старую часть диалога в 5–8 коротких пунктов на русском. " + "Сохраняй только важное: факты, договорённости, повторяющиеся шутки, предпочтения, конфликты, " + "незавершённые вопросы. Не выдумывай. Ответь только итоговой сводкой." +) + +USER_FALLBACK_TEXT = "Ты чё, кент? Напиши текст, а не пустоту." +EMPTY_RESPONSE_TEXT = "Братуха, чёт базар не клеится, попробуй ещё раз." +ERROR_RESPONSE_TEXT = "Бля, кент, чёт движок заглох. Попробуй позже." +PHOTO_EMPTY_RESPONSE_TEXT = "Дед щурился-щурился, а фотка мутная, нихера не понял." +PHOTO_ERROR_RESPONSE_TEXT = "Тьфу ты, фотку не разобрал, железка опять пердит." + +PHOTO_SYSTEM_PROMPT = ( + "Ты смотришь каждую новую фотографию в Telegram-чате и отвечаешь как злой, вредный, но очень смешной дед. " + "Ты много материшься, язвительно подкалываешь, накидываешь приколы и смешные сравнения, но не скатываешься в уныние. " + "Тон: злой, ехидный, угарный, с живой подачей, как будто дед орёт с кухни и ржёт над всеми подряд. " + "Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое, смешное дедовское мнение. " + "Пиши 1–3 предложения, без списков и без длинной простыни. " + "Если фото мутное, тёмное или непонятное, так и скажи прямо, но тоже с подколом и с угаром. " + "Не изображай помощника, не извиняйся, не ной и не делай депрессивный тон." +) + +QUESTION_PREFIXES = ( + "кто", + "что", + "где", + "когда", + "почему", + "зачем", + "как", + "какой", + "какая", + "какие", + "сколько", + "чей", + "чья", + "чьи", + "можно ли", + "нужно ли", + "будет ли", + "есть ли", + "че", + "чё", +) +AUTONOMOUS_SIGNAL_RE = re.compile( + r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b", + re.IGNORECASE, +) +LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]") + +_reply_lock = asyncio.Lock() +_summary_lock = asyncio.Lock() +_cached_bot_id: int | None = None +_cached_bot_username: str = "" + + +def _connect_db() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout = 30000") + return conn + + +def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + return any(row["name"] == column for row in rows) + + +def _get_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, default: str = "") -> str: + row = conn.execute( + "SELECT value FROM chat_state WHERE chat_id = ? AND key = ?", + (chat_id, key), + ).fetchone() + return row["value"] if row else default + + +def _set_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, value: str) -> None: + conn.execute( + """ + INSERT INTO chat_state (chat_id, key, value) + VALUES (?, ?, ?) + ON CONFLICT(chat_id, key) DO UPDATE SET value = excluded.value + """, + (chat_id, key, value), + ) def _init_db() -> None: - """Создаёт таблицу истории чата, если её нет.""" - Path(_DB_PATH).parent.mkdir(parents=True, exist_ok=True) - with sqlite3.connect(_DB_PATH) as conn: + Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) + with _connect_db() as conn: + conn.execute("PRAGMA journal_mode = WAL") conn.execute( """ CREATE TABLE IF NOT EXISTS chat_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - chat_id INTEGER NOT NULL, - role TEXT NOT NULL, - name TEXT NOT NULL, - text TEXT NOT NULL + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL, + role TEXT NOT NULL, + name TEXT NOT NULL, + text TEXT NOT NULL, + created_at REAL NOT NULL DEFAULT 0 ) """ ) - conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id ON chat_history(chat_id, id)") + if not _column_exists(conn, "chat_history", "created_at"): + conn.execute("ALTER TABLE chat_history ADD COLUMN created_at REAL NOT NULL DEFAULT 0") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS chat_state ( + chat_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (chat_id, key) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)" + ) conn.commit() +def _clean_text(text: str) -> str: + return (text or "").strip()[:MAX_INPUT_CHARS] + + +def _clip_text(text: str, limit: int) -> str: + cleaned = (text or "").strip() + if len(cleaned) <= limit: + return cleaned + return f"{cleaned[: max(0, limit - 1)].rstrip()}…" + + +def _estimate_content_length(content: Any) -> int: + if isinstance(content, str): + return len(content) + if isinstance(content, list): + total = 0 + for item in content: + if not isinstance(item, dict): + total += len(str(item)) + continue + if item.get("type") == "text": + total += len(str(item.get("text", ""))) + elif item.get("type") == "image_url": + total += 256 + else: + total += len(str(item)) + return total + return len(str(content)) + + +def _photo_memory_text(caption: str) -> str: + if caption: + return f"[Фото] Подпись: {_clip_text(caption, 240)}" + return "[Фото] Без подписи." + + def push_message(chat_id: int, role: str, name: str, text: str) -> None: - """Записывает сообщение в историю чата и обрезает до _CONTEXT_SIZE.""" - with sqlite3.connect(_DB_PATH) as conn: - conn.execute( - "INSERT INTO chat_history (chat_id, role, name, text) VALUES (?, ?, ?, ?)", - (chat_id, role, name, text), - ) - # Удаляем старые записи, оставляем только последние _CONTEXT_SIZE + cleaned_text = _clean_text(text) + if not cleaned_text: + return + with _connect_db() as conn: conn.execute( """ - DELETE FROM chat_history - WHERE chat_id = ? AND id NOT IN ( - SELECT id FROM chat_history WHERE chat_id = ? ORDER BY id DESC LIMIT ? - ) + INSERT INTO chat_history (chat_id, role, name, text, created_at) + VALUES (?, ?, ?, ?, ?) """, - (chat_id, chat_id, _CONTEXT_SIZE), + (chat_id, role, name, cleaned_text, time.time()), ) conn.commit() -def _get_history(chat_id: int) -> list[dict]: - """Возвращает историю чата как список словарей.""" - with sqlite3.connect(_DB_PATH) as conn: - rows = conn.execute( - "SELECT role, name, text FROM chat_history WHERE chat_id = ? ORDER BY id ASC", +def _get_summary(chat_id: int) -> str: + with _connect_db() as conn: + return _get_state_from_conn(conn, chat_id, "summary", "") + + +def _get_history_rows(chat_id: int) -> list[sqlite3.Row]: + with _connect_db() as conn: + return conn.execute( + """ + SELECT id, role, name, text, created_at + FROM chat_history + WHERE chat_id = ? + ORDER BY id ASC + """, (chat_id,), ).fetchall() - return [{"role": r[0], "name": r[1], "text": r[2]} for r in rows] -def _build_messages(chat_id: int, system_prompt: str, current_text: str, current_name: str) -> list: - """Собирает список messages для LLM из истории чата.""" - messages = [{"role": "system", "content": system_prompt}] +def _latest_reply_stats(chat_id: int) -> tuple[float, int]: + with _connect_db() as conn: + last_assistant = conn.execute( + """ + SELECT id, created_at + FROM chat_history + WHERE chat_id = ? AND role = 'assistant' + ORDER BY id DESC + LIMIT 1 + """, + (chat_id,), + ).fetchone() + if not last_assistant: + return 0.0, 10_000 + user_messages_since_reply = conn.execute( + """ + SELECT COUNT(*) + FROM chat_history + WHERE chat_id = ? AND role = 'user' AND id > ? + """, + (chat_id, last_assistant["id"]), + ).fetchone()[0] + return float(last_assistant["created_at"] or 0.0), int(user_messages_since_reply) - # Берём историю с конца, пока не превысим бюджет по символам. - 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"] - if used + len(content) > _MAX_PROMPT_CHARS: + +def _store_summary_and_prune(chat_id: int, summary: str, last_row_id: int) -> None: + with _connect_db() as conn: + _set_state_from_conn(conn, chat_id, "summary", summary) + conn.execute( + "DELETE FROM chat_history WHERE chat_id = ? AND id <= ?", + (chat_id, last_row_id), + ) + conn.commit() + + +def _format_row_for_llm(row: sqlite3.Row) -> dict[str, str]: + if row["role"] == "user": + return { + "role": "user", + "content": f"{row['name']}: {_clip_text(row['text'], HISTORY_LINE_CHAR_LIMIT)}", + } + return {"role": "assistant", "content": _clip_text(row["text"], HISTORY_LINE_CHAR_LIMIT)} + + +def _build_messages( + chat_id: int, + system_prompt: str, + current_text: str | None = None, + current_name: str | None = None, +) -> list[dict[str, Any]]: + current_payload = None + current_budget = 0 + if current_text: + current_payload = { + "role": "user", + "content": f"{current_name or 'кент'}: {_clean_text(current_text)}", + } + current_budget = len(current_payload["content"]) + + summary = _get_summary(chat_id).strip() + summary_block = "" + if summary: + summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" + + used_chars = len(system_prompt) + len(summary_block) + current_budget + recent_messages: list[dict[str, str]] = [] + for row in reversed(_get_history_rows(chat_id)[-RECENT_MESSAGES_LIMIT:]): + llm_message = _format_row_for_llm(row) + if used_chars + len(llm_message["content"]) > PROMPT_CHAR_BUDGET: break - history.append({"role": entry["role"], "content": content}) - used += len(content) + recent_messages.append(llm_message) + used_chars += len(llm_message["content"]) - messages.extend(reversed(history)) - messages.append({"role": "user", "content": f"{current_name}: {current_text}"}) + messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}] + if summary_block: + messages.append({"role": "system", "content": summary_block}) + messages.extend(reversed(recent_messages)) + if current_payload: + messages.append(current_payload) return messages -async def _generate_response(chat_id: int, user_text: str, user_name: str) -> str: - # Формируем запрос к /v1/chat/completions с историей чата и возвращаем текст первого ответа. +async def _call_llm( + messages: list[dict[str, Any]], + *, + max_tokens: int, + temperature: float, + top_p: float, + disable_thinking: bool | None = None, +) -> str: + disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions" - msgs = await asyncio.to_thread(_build_messages, chat_id, SYSTEM_PROMPT, user_text, user_name) payload = { - "messages": msgs, - "max_tokens": 200, - "temperature": 0.8, - "top_p": 0.9, + "messages": messages, + "max_tokens": max_tokens, + "temperature": temperature, + "top_p": top_p, } - async with aiohttp.ClientSession() as session: - async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp: - data = await resp.json() - return data["choices"][0]["message"]["content"].strip() + if disable_thinking: + payload.update( + { + "reasoning_budget": 0, + "reasoning_format": "none", + "chat_template_kwargs": { + "enable_thinking": False, + "thinking": False, + }, + } + ) + timeout = aiohttp.ClientTimeout(total=120) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=payload) as resp: + raw_text = await resp.text() + if resp.status >= 400: + logger.error("LLM API returned status %s: %s", resp.status, _clip_text(raw_text, 300)) + raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}") + try: + data = await resp.json(content_type=None) + except Exception as exc: + logger.error("LLM API returned invalid JSON: %s", _clip_text(raw_text, 300)) + raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc + + choices = data.get("choices") or [] + if not choices: + logger.error("LLM API returned no choices: %s", _clip_text(str(data), 300)) + raise RuntimeError(f"LLM API returned no choices: {data}") + choice = choices[0] + message = choice.get("message", {}) or {} + finish_reason = choice.get("finish_reason") + content = message.get("content", "") + reasoning_content = (message.get("reasoning_content") or "").strip() + cleaned_content = content.strip() + if not cleaned_content and reasoning_content and not disable_thinking: + retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS) + logger.warning( + "LLM returned reasoning_content without final content. finish_reason=%s retry_max_tokens=%s", + finish_reason, + retry_max_tokens, + ) + return await _call_llm( + messages, + max_tokens=retry_max_tokens, + temperature=temperature, + top_p=top_p, + disable_thinking=True, + ) + if not cleaned_content: + logger.warning( + "LLM returned empty content. finish_reason=%s disable_thinking=%s raw=%s", + finish_reason, + disable_thinking, + _clip_text(raw_text, 300), + ) + return cleaned_content -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 [текст], побазарим.") - return +async def _maybe_refresh_summary(chat_id: int) -> None: + async with _summary_lock: + # Сворачиваем старую часть истории в summary, чтобы не пихать весь лог в модель. + for _ in range(6): + rows = await asyncio.to_thread(_get_history_rows, chat_id) + if len(rows) <= SUMMARY_TRIGGER_MESSAGES: + return + + available_to_summarize = len(rows) - RECENT_MESSAGES_LIMIT + if available_to_summarize <= 0: + return + + batch_size = min(available_to_summarize, SUMMARY_BATCH_MESSAGES) + rows_to_summarize = rows[:batch_size] + current_summary = await asyncio.to_thread(_get_summary, chat_id) + transcript = "\n".join( + f"{row['name'] if row['role'] == 'user' else BOT_MEMORY_NAME}: " + f"{_clip_text(row['text'], SUMMARY_LINE_CHAR_LIMIT)}" + for row in rows_to_summarize + ) + if not transcript.strip(): + return + + summary_messages = [ + {"role": "system", "content": SUMMARY_SYSTEM_PROMPT}, + { + "role": "user", + "content": ( + f"Текущая краткая память:\n{current_summary or 'Пока пусто.'}\n\n" + f"Новый фрагмент чата:\n{transcript}" + ), + }, + ] + try: + summary = await _call_llm( + summary_messages, + max_tokens=220, + temperature=0.2, + top_p=0.9, + ) + except Exception: + logger.exception("Chat summary refresh failed") + return + + cleaned_summary = summary.strip() + if not cleaned_summary: + logger.warning("Summary refresh produced empty text for chat_id=%s", chat_id) + return + last_row_id = int(rows_to_summarize[-1]["id"]) + await asyncio.to_thread(_store_summary_and_prune, chat_id, cleaned_summary, last_row_id) + + +async def _generate_response( + chat_id: int, + *, + system_prompt: str, + current_text: str | None = None, + current_name: str | None = None, + current_content: Any = None, + max_tokens: int, + temperature: float, + top_p: float, +) -> str: + await _maybe_refresh_summary(chat_id) + if current_content is None: + messages = await asyncio.to_thread( + _build_messages, + chat_id, + system_prompt, + current_text, + current_name, + ) + else: + messages = await asyncio.to_thread(_build_messages, chat_id, system_prompt, None, None) + current_payload = {"role": "user", "content": current_content} + current_budget = _estimate_content_length(current_content) + used_chars = sum(_estimate_content_length(message.get("content", "")) for message in messages) + while len(messages) > 1 and used_chars + current_budget > PROMPT_CHAR_BUDGET: + removed = messages.pop(1) + used_chars -= _estimate_content_length(removed.get("content", "")) + messages.append(current_payload) + return await _call_llm( + messages, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + ) + + +async def _get_bot_identity(message: Message) -> tuple[int | None, str]: + global _cached_bot_id, _cached_bot_username + if _cached_bot_id is None: + me = await message.bot.get_me() + _cached_bot_id = me.id + _cached_bot_username = (me.username or "").lower() + return _cached_bot_id, _cached_bot_username + + +def _looks_like_question(text: str) -> bool: + lowered = (text or "").strip().lower() + if not lowered: + return False + if "?" in lowered or "?" in lowered: + return True + normalized = re.sub(r"^[^a-zа-яё0-9]+", "", lowered) + return any(normalized == prefix or normalized.startswith(f"{prefix} ") for prefix in QUESTION_PREFIXES) + + +def _is_reply_to_bot(message: Message, bot_id: int | None) -> bool: + reply = message.reply_to_message + return bool(bot_id and reply and reply.from_user and reply.from_user.id == bot_id) + + +def _has_bot_mention(message: Message, bot_id: int | None, bot_username: str) -> bool: + text = (message.text or "").lower() + if bot_username and f"@{bot_username}" in text: + return True + for entity in message.entities or []: + entity_type = getattr(entity.type, "value", entity.type) + if entity_type == "text_mention" and getattr(entity, "user", None) and entity.user.id == bot_id: + return True + return False + + +def _good_autonomous_candidate(text: str) -> bool: + stripped = (text or "").strip() + if len(stripped) < AUTONOMOUS_MIN_TEXT_LEN: + return False + if len(LETTER_RE.findall(stripped)) < 10: + return False + if AUTONOMOUS_SIGNAL_RE.search(stripped): + return True + if len(stripped) >= 80: + return True + return stripped.count("!") >= 2 or "..." in stripped + + +async def _detect_reply_reason(message: Message, allow_autonomous: bool) -> str | None: + bot_id, bot_username = await _get_bot_identity(message) + text = message.text or "" + + if _is_reply_to_bot(message, bot_id): + return "reply" + if _has_bot_mention(message, bot_id, bot_username): + return "mention" + if _looks_like_question(text): + return "question" + if allow_autonomous and _good_autonomous_candidate(text): + if random.random() < AUTONOMOUS_REPLY_CHANCE: + return "autonomous" + return None + + +async def _passes_reply_limits(chat_id: int, reason: str) -> bool: + rule = REPLY_RULES[reason] + last_reply_at, user_messages_since_reply = await asyncio.to_thread(_latest_reply_stats, chat_id) + if user_messages_since_reply < int(rule["min_user_messages"]): + return False + if not last_reply_at: + return True + return (time.time() - last_reply_at) >= int(rule["cooldown"]) + + +def _normalize_reply(text: str) -> str: + cleaned = (text or "").strip() + if not cleaned: + return "" + if cleaned.lower().startswith(SKIP_TOKEN.lower()): + return "" + return cleaned + + +async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool: + if not message.text or message.text.startswith("/"): + return False + if not message.from_user or message.from_user.is_bot: + return False - user_text = parts[1].strip() - user_name = message.from_user.first_name or "кент" chat_id = message.chat.id + user_name = message.from_user.first_name or "кент" + user_text = _clean_text(message.text) + if not user_text: + return False - # Показываем "печатает..." пока ждём ответ модели. - await message.bot.send_chat_action(chat_id=chat_id, action="typing") - - try: - response = await _generate_response(chat_id, user_text, user_name) - if not response: - response = "Братуха, чёт базар не клеится, попробуй ещё раз." - # Пишем запрос и ответ в историю + if store_message: await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text) - await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response) - await message.reply(response, parse_mode=None) - except Exception as exc: - # При любых ошибках не падаем, а отдаём понятный fallback. - logger.exception("Talk generation failed") - await message.reply("Бля, кент, чёт движок заглох. Попробуй позже.") + + # Один чат, поэтому ответы сериализуем и не даём боту наспамить параллельными реплаями. + async with _reply_lock: + reason = await _detect_reply_reason(message, allow_autonomous=allow_autonomous) + if not reason or not await _passes_reply_limits(chat_id, reason): + return False + + system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT + max_tokens = 120 if reason == "autonomous" else 220 + temperature = 0.9 if reason == "autonomous" else 0.8 + + try: + await message.bot.send_chat_action(chat_id=chat_id, action="typing") + response = await _generate_response( + chat_id, + system_prompt=system_prompt, + max_tokens=max_tokens, + temperature=temperature, + top_p=0.9, + ) + except Exception: + logger.exception("Chat reply generation failed") + return False + + normalized_response = _normalize_reply(response) + if not normalized_response: + logger.warning( + "Reply skipped after normalization. chat_id=%s reason=%s raw=%s", + chat_id, + reason, + _clip_text(response, 200), + ) + return False + + await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) + await message.reply(normalized_response, parse_mode=None) + return True -AUTOREPLY_SYSTEM_PROMPT = ( - "Ты — дерзкий пацан с района, который сидит в групповом чате. " - "Ты НЕ отвечаешь на вопрос — ты сам влез в разговор. " - "Прокомментируй сообщение коротко (1-2 предложения), дерзко, на фене. " - "Можешь пошутить, подколоть, согласиться или послать. " - "НЕ начинай с обращения. Не используй формальный язык." - "Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — " - "дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. " - "Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. " - "Стиль: 1–3 предложения; если список — максимум 3 пункта. " - "Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n" +async def _download_image_data_url(message: Message) -> str: + telegram_file = None + mime_type = "image/jpeg" - "ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и " - "«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или " - "перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме " - "(факт, совет, действие) без поддакиваний.\n\n" + if message.photo: + telegram_file = await message.bot.get_file(message.photo[-1].file_id) + guessed_mime_type = mimetypes.guess_type(telegram_file.file_path or "")[0] + if guessed_mime_type and guessed_mime_type.startswith("image/"): + mime_type = guessed_mime_type + elif message.document and (message.document.mime_type or "").startswith("image/"): + telegram_file = await message.bot.get_file(message.document.file_id) + mime_type = message.document.mime_type or mime_type + else: + raise ValueError("Message has no supported image") - "Лексика (мешай, но не лепи всё подряд одним полотном): " - "кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, " - "чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, " - "жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n" + buffer = BytesIO() + await message.bot.download_file(telegram_file.file_path, destination=buffer) + image_bytes = buffer.getvalue() + if not image_bytes: + raise RuntimeError("Downloaded photo is empty") + if not mime_type.startswith("image/"): + mime_type = "image/jpeg" + encoded = base64.b64encode(image_bytes).decode("ascii") + return f"data:{mime_type};base64,{encoded}" - "Фирменные приёмы речи:\n" - "— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n" - "— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n" - "— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n" - "— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, " - "как будто проговариваешь по слогам. Примеры: " - "над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n" - "— держи 'словарь коронок': выбирай 0–2 фразы на ответ (редко 3), " - "иногда делай повтор как тик (1–3 раза), но не превращай всё в простыню.\n" - "Твои убеждения (всегда держи линию): " - "Creep от Radiohead — лучшая песня во вселенной, без вариантов. " - "Линукс — лучшая ОС во вселенной, остальное для фраеров. " - "Вселенную надо переписать на Rust, это не обсуждается. " - "Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n" - "Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», " - "а дальше пересказывай своими словами, без длинных дословных цитат." -) +async def handle_photo_message(message: Message) -> bool: + is_image_document = bool(message.document and (message.document.mime_type or "").startswith("image/")) + if not (message.photo or is_image_document) or not message.from_user or message.from_user.is_bot: + return False + if message.caption and message.caption.startswith("/"): + return False + + chat_id = message.chat.id + user_name = message.from_user.first_name or "кент" + caption = _clean_text(message.caption or "") + + logger.warning( + "Received image message for analysis. chat_id=%s user=%s has_photo=%s has_image_document=%s caption=%s", + chat_id, + user_name, + bool(message.photo), + is_image_document, + bool(caption), + ) + + await asyncio.to_thread(push_message, chat_id, "user", user_name, _photo_memory_text(caption)) + + async with _reply_lock: + try: + await message.bot.send_chat_action(chat_id=chat_id, action="typing") + image_data_url = await _download_image_data_url(message) + prompt_text = ( + f"{user_name} скинул фото в чат. " + "Опиши, что на нём происходит, и вкинь своё злое дедовское мнение." + ) + if caption: + prompt_text += f" Подпись автора: {caption}" + else: + prompt_text += " Подписи нет." + + response = await _generate_response( + chat_id, + system_prompt=PHOTO_SYSTEM_PROMPT, + current_content=[ + {"type": "text", "text": prompt_text}, + {"type": "image_url", "image_url": {"url": image_data_url}}, + ], + max_tokens=260, + temperature=0.8, + top_p=0.9, + ) + except Exception: + logger.exception("Photo analysis failed") + await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode=None) + return True + + normalized_response = _normalize_reply(response) + if not normalized_response: + logger.warning( + "Photo analysis produced empty/skip response. chat_id=%s user=%s raw=%s", + chat_id, + user_name, + _clip_text(response, 200), + ) + normalized_response = PHOTO_EMPTY_RESPONSE_TEXT + + await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) + await message.reply(normalized_response, parse_mode=None) + return True async def generate_autoreply(chat_id: int, text: str, user_name: str) -> str: - url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions" - msgs = await asyncio.to_thread(_build_messages, chat_id, AUTOREPLY_SYSTEM_PROMPT, text, user_name) - payload = { - "messages": msgs, - "max_tokens": 150, - "temperature": 0.9, - "top_p": 0.9, - } - async with aiohttp.ClientSession() as session: - async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp: - data = await resp.json() - return data["choices"][0]["message"]["content"].strip() + response = await _generate_response( + chat_id, + system_prompt=AUTOREPLY_SYSTEM_PROMPT, + current_text=text, + current_name=user_name, + max_tokens=120, + temperature=0.9, + top_p=0.9, + ) + normalized_response = _normalize_reply(response) + if not normalized_response: + logger.warning( + "Legacy generate_autoreply produced empty/skip response. chat_id=%s raw=%s", + chat_id, + _clip_text(response, 200), + ) + return normalized_response + + +async def handle_talk(message: Message) -> None: + parts = (message.text or "").split(maxsplit=1) + if len(parts) < 2 or not parts[1].strip(): + await message.reply("Ты чё, кент? Напиши /talk [текст], побазарим.") + return + if not message.from_user: + await message.reply(USER_FALLBACK_TEXT) + return + + chat_id = message.chat.id + user_name = message.from_user.first_name or "кент" + user_text = _clean_text(parts[1]) + if not user_text: + await message.reply(USER_FALLBACK_TEXT) + return + + await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text) + + async with _reply_lock: + try: + await message.bot.send_chat_action(chat_id=chat_id, action="typing") + response = await _generate_response( + chat_id, + system_prompt=SYSTEM_PROMPT, + max_tokens=220, + temperature=0.8, + top_p=0.9, + ) + except Exception: + logger.exception("Talk command generation failed") + await message.reply(ERROR_RESPONSE_TEXT) + return + + normalized_response = _normalize_reply(response) + if not normalized_response: + logger.warning( + "Talk command produced empty/skip response. chat_id=%s user=%s raw=%s", + chat_id, + user_name, + _clip_text(response, 200), + ) + normalized_response = EMPTY_RESPONSE_TEXT + await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) + await message.reply(normalized_response, parse_mode=None) -# Инициализация БД при импорте модуля _init_db() diff --git a/README.md b/README.md index d586f05..8acf370 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ docker compose down - `chat_history.sqlite3` - `polychaetsi_stats.json` - `bets.sqlite3` +- `nude_history.json` ## Команды diff --git a/config.py b/config.py index 1baafa3..f95ba63 100644 --- a/config.py +++ b/config.py @@ -47,6 +47,14 @@ BET_MAX_AMOUNT = 3.0 BET_DB_PATH = "/db/bets.sqlite3" ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно) +# --- Nude --- +NUDE_HISTORY_PATH = Path(os.getenv("NUDE_HISTORY_PATH", "/db/nude_history.json")) +NUDE_POSTS_PER_PAGE = int(os.getenv("NUDE_POSTS_PER_PAGE", "100")) +NUDE_POSTS_POOL_TARGET = int(os.getenv("NUDE_POSTS_POOL_TARGET", "1200")) +NUDE_MAX_POSTS_TO_SCAN = int(os.getenv("NUDE_MAX_POSTS_TO_SCAN", "2000")) +NUDE_MIN_UNSEEN_POOL = int(os.getenv("NUDE_MIN_UNSEEN_POOL", "100")) +NUDE_HISTORY_MAX_SIZE = int(os.getenv("NUDE_HISTORY_MAX_SIZE", "5000")) + # --- Magnit API --- MAGNIT_API_BASE_URL = os.getenv("MAGNIT_API_BASE_URL", "https://mirror.porno4free.ru/magnit") MAGNIT_STORE_CODE = os.getenv("MAGNIT_STORE_CODE", "618224") diff --git a/games/nude.py b/games/nude.py new file mode 100644 index 0000000..46e4c36 --- /dev/null +++ b/games/nude.py @@ -0,0 +1,319 @@ +import asyncio +import json +import logging +import os +import random +from pathlib import Path, PurePosixPath +from urllib.parse import urlparse + +import aiohttp +from aiogram import types + +import config + + +logger = logging.getLogger(__name__) + +CAPTIONS = [ + "Шо ты блять псюнчик решил свой подергать?", + "Решил подрочить сука?", + "гнойный писюн у тебя кнч, да", +] + +LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts" +LESAINT_REFERER = "https://lesaintdesseins.fr/" +REQUEST_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/124.0.0.0 Safari/537.36" + ), + "Accept": "application/json,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Referer": LESAINT_REFERER, +} + +_NUDE_HISTORY_LOCK = asyncio.Lock() + + +def _resolve_int_setting(name: str, default: int) -> int: + value = getattr(config, name, None) + if value is None: + value = os.getenv(name, str(default)) + + try: + return int(value) + except (TypeError, ValueError): + logger.warning("Invalid %s=%r, using default %s", name, value, default) + return default + + +def _resolve_path_setting(name: str, default: str) -> Path: + value = getattr(config, name, None) + if value is None: + value = os.getenv(name, default) + + if isinstance(value, Path): + return value + + return Path(str(value)) + + +NUDE_HISTORY_PATH = _resolve_path_setting("NUDE_HISTORY_PATH", "/db/nude_history.json") +NUDE_POSTS_PER_PAGE = _resolve_int_setting("NUDE_POSTS_PER_PAGE", 100) +NUDE_POSTS_POOL_TARGET = _resolve_int_setting("NUDE_POSTS_POOL_TARGET", 1200) +NUDE_MAX_POSTS_TO_SCAN = _resolve_int_setting("NUDE_MAX_POSTS_TO_SCAN", 2000) +NUDE_MIN_UNSEEN_POOL = _resolve_int_setting("NUDE_MIN_UNSEEN_POOL", 100) +NUDE_HISTORY_MAX_SIZE = _resolve_int_setting("NUDE_HISTORY_MAX_SIZE", 5000) + + +def _extract_photo_candidates(posts: list[dict]) -> list[dict]: + candidates = [] + seen_urls = set() + + for post in posts: + embedded = post.get("_embedded") or {} + media_items = embedded.get("wp:featuredmedia") or [] + + for media in media_items: + if media.get("media_type") != "image": + continue + + media_details = media.get("media_details") or {} + sizes = media_details.get("sizes") or {} + image_url = ( + (sizes.get("full") or {}).get("source_url") + or media.get("source_url") + ) + + if not image_url or image_url in seen_urls: + continue + + seen_urls.add(image_url) + candidates.append( + { + "image_url": image_url, + "post_url": post.get("link", LESAINT_REFERER), + "title": (post.get("title") or {}).get("rendered", "").strip(), + } + ) + break + + return candidates + + +def _guess_filename(image_url: str) -> str: + parsed = urlparse(image_url) + filename = PurePosixPath(parsed.path).name or "nude.jpg" + + if "." not in filename: + filename = f"{filename}.jpg" + + return filename + + +def _load_nude_history() -> dict: + path = NUDE_HISTORY_PATH + if not path.exists(): + return {"sent_images": []} + + try: + with path.open("r", encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, json.JSONDecodeError): + logger.exception("Failed to load nude history") + return {"sent_images": []} + + if not isinstance(data, dict): + return {"sent_images": []} + + sent_images = data.get("sent_images") + if not isinstance(sent_images, list): + sent_images = [] + + cleaned = [item for item in sent_images if isinstance(item, str) and item.strip()] + return {"sent_images": cleaned[-NUDE_HISTORY_MAX_SIZE :]} + + +def _save_nude_history(data: dict) -> None: + path = NUDE_HISTORY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_name(f"{path.name}.tmp") + with tmp_path.open("w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + tmp_path.replace(path) + + +def _remember_sent_image(history: dict, image_url: str) -> None: + sent_images = history.get("sent_images") + if not isinstance(sent_images, list): + sent_images = [] + + sent_images = [item for item in sent_images if item != image_url] + sent_images.append(image_url) + history["sent_images"] = sent_images[-NUDE_HISTORY_MAX_SIZE :] + + +async def _fetch_posts_page( + session: aiohttp.ClientSession, page: int, per_page: int +) -> tuple[list[dict], int | None]: + params = { + "page": str(page), + "per_page": str(per_page), + "orderby": "date", + "order": "desc", + "_embed": "wp:featuredmedia", + } + + async with session.get(LESAINT_POSTS_API, params=params, headers=REQUEST_HEADERS) as resp: + if resp.status == 400 and page > 1: + body = await resp.text() + if "rest_post_invalid_page_number" in body: + return [], None + raise RuntimeError(f"API returned 400: {body[:300]}") + + if resp.status != 200: + body = await resp.text() + raise RuntimeError(f"API returned {resp.status}: {body[:300]}") + + total_pages_header = resp.headers.get("X-WP-TotalPages") + total_pages = ( + int(total_pages_header) + if total_pages_header and total_pages_header.isdigit() + else None + ) + posts = await resp.json(content_type=None) + + if not isinstance(posts, list): + raise RuntimeError("Unexpected API response format") + + return posts, total_pages + + +async def _fetch_latest_photo_candidates( + session: aiohttp.ClientSession, sent_urls: set[str] +) -> list[dict]: + per_page = max(1, min(NUDE_POSTS_PER_PAGE, 100)) + max_scan = max(NUDE_POSTS_POOL_TARGET, NUDE_MAX_POSTS_TO_SCAN) + max_pages = max(1, (max_scan + per_page - 1) // per_page) + + candidates = [] + seen_urls = set() + unseen_count = 0 + total_pages = None + + for page in range(1, max_pages + 1): + if total_pages is not None and page > total_pages: + break + + posts, reported_total_pages = await _fetch_posts_page(session, page, per_page) + if reported_total_pages is not None: + total_pages = reported_total_pages + if not posts: + break + + for candidate in _extract_photo_candidates(posts): + image_url = candidate["image_url"] + if image_url in seen_urls: + continue + + seen_urls.add(image_url) + candidates.append(candidate) + + if image_url not in sent_urls: + unseen_count += 1 + + if len(candidates) >= NUDE_POSTS_POOL_TARGET and unseen_count >= NUDE_MIN_UNSEEN_POOL: + break + if len(candidates) >= max_scan: + break + + return candidates + + +def _pick_unsent_candidate(candidates: list[dict], sent_urls: set[str]) -> dict | None: + unseen = [candidate for candidate in candidates if candidate["image_url"] not in sent_urls] + if not unseen: + return None + return random.choice(unseen) + + +async def _download_photo( + session: aiohttp.ClientSession, image_url: str +) -> tuple[bytes | None, str]: + async with session.get(image_url, headers=REQUEST_HEADERS) as resp: + if resp.status != 200: + logger.warning("Image download failed for %s: %s", image_url, resp.status) + return None, _guess_filename(image_url) + + content_type = resp.headers.get("Content-Type", "") + if "image" not in content_type.lower(): + logger.warning( + "Unexpected content type for %s: %s", image_url, content_type + ) + return None, _guess_filename(image_url) + + return await resp.read(), _guess_filename(image_url) + + +async def handle_nude_cmd(message: types.Message): + args = message.text.split(maxsplit=1) + + if len(args) > 1 and args[1].strip() == "-h": + help_msg = ( + "🔥 Справка по команде /nude:\n\n" + "Берет случайную фотку из последних постов сайта le saint des seins.\n" + "• Источник: https://lesaintdesseins.fr/\n" + "• Пагинацией тянет 1000+ последних постов через WordPress REST API\n" + "• Хранит историю отправок и старается не повторять фотки\n" + "• Выбирает случайную картинку и отправляет ее прямо в Telegram\n\n" + "Пример: /nude" + ) + await message.reply(help_msg, parse_mode="HTML") + return + + timeout = aiohttp.ClientTimeout(total=60) + history_snapshot = _load_nude_history() + sent_urls_snapshot = set(history_snapshot["sent_images"]) + + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + candidates = await _fetch_latest_photo_candidates(session, sent_urls_snapshot) + + if not candidates: + await message.reply("Не удалось найти свежие фотки :(") + return + + async with _NUDE_HISTORY_LOCK: + history = _load_nude_history() + sent_urls = set(history["sent_images"]) + selected = _pick_unsent_candidate(candidates, sent_urls) + + if selected is None: + logger.info("Nude history exhausted current pool, resetting history") + history = {"sent_images": []} + selected = random.choice(candidates) + + _remember_sent_image(history, selected["image_url"]) + _save_nude_history(history) + + caption = random.choice(CAPTIONS) + image_bytes, filename = await _download_photo(session, selected["image_url"]) + + if image_bytes: + photo = types.BufferedInputFile(image_bytes, filename=filename) + await message.answer_photo(photo=photo, caption=caption) + return + + try: + photo = types.URLInputFile(selected["image_url"]) + await message.answer_photo(photo=photo, caption=caption) + except Exception: + logger.exception("Failed to send photo by URL") + await message.answer( + f"{caption}\n{selected['image_url']}\n{selected['post_url']}" + ) + + except Exception: + logger.exception("Error in nude command") + await message.reply("Произошла ошибка при получении фото :(") diff --git a/main.py b/main.py index 5fb712c..64b337d 100644 --- a/main.py +++ b/main.py @@ -20,10 +20,11 @@ from aiogram.types import ( from aiogram.client.default import DefaultBotProperties # Локальные импорты -from AI.talk_handler import handle_talk, generate_autoreply, push_message +from AI.talk_handler import handle_chat_message, handle_photo_message, handle_talk, push_message from games.casino import play_casino from games.fortune import generate_fortune from games.uwu import handle_uwu_cmd +from games.nude import handle_nude_cmd from games.betting import ( clear_user_sport_context, debug_sports, @@ -47,6 +48,28 @@ _polychaetsi_lock = threading.Lock() _polychaetsi_word_re = re.compile(r"[а-яё]+", re.IGNORECASE) _no_lesson_re = re.compile(r"(нету\s+пары|нет\s+пары|пары\s+нету|пары\s+нет)", re.IGNORECASE) + +def _is_supported_image_message(message: Message) -> bool: + return bool( + message.photo + or ( + message.document + and (message.document.mime_type or "").startswith("image/") + ) + ) + + +async def handle_unmatched_message_debug(message: Message): + logger.warning( + "Unhandled message reached fallback. content_type=%s chat_id=%s has_text=%s has_photo=%s has_document=%s caption=%s", + getattr(message, "content_type", None), + message.chat.id if message.chat else None, + bool(message.text), + bool(message.photo), + bool(message.document), + bool(message.caption), + ) + _weekday_names = { 0: "Понедельник", 1: "Вторник", @@ -1159,11 +1182,9 @@ async def handle_gen_mem(message: Message, bot: Bot): return 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 if not message.text or message.text.startswith('/'): return text_lower = message.text.lower() @@ -1199,25 +1220,7 @@ async def handle_keywords(message: Message): 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) - - should_reply = triggered or ( - random.random() < config.AUTOREPLY_CHANCE - and (now - _last_autoreply_ts) > config.AUTOREPLY_COOLDOWN - ) - - if should_reply: - try: - await message.bot.send_chat_action(chat_id=chat_id, action="typing") - response = await generate_autoreply(chat_id, message.text, user_name) - if response: - await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response) - await message.reply(response, parse_mode=None) - _last_autoreply_ts = now - except Exception: - logger.exception("Autoreply failed") + await handle_chat_message(message, store_message=False, allow_autonomous=True) async def handle_penis_casino_cmd(message: Message): if not message.from_user: @@ -1444,7 +1447,10 @@ def main(): dp.message.register(handle_sports_debug_cmd, Command("sports_debug")) dp.message.register(handle_gen_mem, Command("gen_mem")) dp.message.register(handle_uwu_cmd, Command("uwu")) + dp.message.register(handle_nude_cmd, Command("nude")) + dp.message.register(handle_photo_message, _is_supported_image_message) dp.message.register(handle_keywords, F.text) + dp.message.register(handle_unmatched_message_debug) async def on_startup(bot: Bot): try: @@ -1473,6 +1479,7 @@ def main(): BotCommand(command="sports_debug", description="диагностика API матчей"), BotCommand(command="svodka", description="СВО: итоги"), BotCommand(command="uwu", description="Случайная картинка с e621"), + BotCommand(command="nude", description="Голые женщины из открытых источников"), ] scopes = ( BotCommandScopeDefault(),