From 4d0a3841b7ed03ad558be44e377c7b7a5ab87155 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 16:15:14 +0300 Subject: [PATCH] update ai dolboeba --- AI/talk_handler.py | 710 +++++++++++++++++++++++++++++++++------------ README.md | 1 + config.py | 8 + main.py | 24 +- 4 files changed, 543 insertions(+), 200 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 2758c97..6139bd7 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -1,7 +1,12 @@ +from __future__ import annotations + import asyncio -import os import logging +import os +import random +import re import sqlite3 +import time from pathlib import Path import aiohttp @@ -9,236 +14,585 @@ 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 = "" - "ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и " - "«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или " - "перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме " - "(факт, совет, действие) без поддакиваний.\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 - "Лексика (мешай, но не лепи всё подряд одним полотном): " - "кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, " - "чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, " - "жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\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. " + "Убунту — хуйня, нормальные пацаны сидят на 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 = "Бля, кент, чёт движок заглох. Попробуй позже." + +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 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, str]]: + 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 = [{"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, str]], + *, + max_tokens: int, + temperature: float, + top_p: float, +) -> str: 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() + 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: + raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}") + try: + data = await resp.json(content_type=None) + except Exception as exc: + raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc + + choices = data.get("choices") or [] + if not choices: + raise RuntimeError(f"LLM API returned no choices: {data}") + content = choices[0].get("message", {}).get("content", "") + return content.strip() -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: + 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, + max_tokens: int, + temperature: float, + top_p: float, +) -> str: + await _maybe_refresh_summary(chat_id) + messages = await asyncio.to_thread( + _build_messages, + chat_id, + system_prompt, + current_text, + current_name, + ) + 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 -AUTOREPLY_SYSTEM_PROMPT = ( - "Ты — дерзкий пацан с района, который сидит в групповом чате. " - "Ты НЕ отвечаешь на вопрос — ты сам влез в разговор. " - "Прокомментируй сообщение коротко (1-2 предложения), дерзко, на фене. " - "Можешь пошутить, подколоть, согласиться или послать. " - "НЕ начинай с обращения. Не используй формальный язык." - "Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — " - "дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. " - "Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. " - "Стиль: 1–3 предложения; если список — максимум 3 пункта. " - "Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n" + 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 - "ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и " - "«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или " - "перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме " - "(факт, совет, действие) без поддакиваний.\n\n" + 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 - "Лексика (мешай, но не лепи всё подряд одним полотном): " - "кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, " - "чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, " - "жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n" + normalized_response = _normalize_reply(response) + if not normalized_response: + return False - "Фирменные приёмы речи:\n" - "— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n" - "— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n" - "— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n" - "— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, " - "как будто проговариваешь по слогам. Примеры: " - "над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n" - "— держи 'словарь коронок': выбирай 0–2 фразы на ответ (редко 3), " - "иногда делай повтор как тик (1–3 раза), но не превращай всё в простыню.\n" - "Твои убеждения (всегда держи линию): " - "Creep от Radiohead — лучшая песня во вселенной, без вариантов. " - "Линукс — лучшая ОС во вселенной, остальное для фраеров. " - "Вселенную надо переписать на Rust, это не обсуждается. " - "Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n" - - "Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», " - "а дальше пересказывай своими словами, без длинных дословных цитат." -) + 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, + ) + return _normalize_reply(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) or 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/main.py b/main.py index 7891bde..9da20a2 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,7 @@ 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_talk, push_message from games.casino import play_casino from games.fortune import generate_fortune from games.uwu import handle_uwu_cmd @@ -1134,11 +1134,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() @@ -1174,25 +1172,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: