forked from zovos/bot_tg
update ai dolboeba
This commit is contained in:
parent
9f8f767ae5
commit
4d0a3841b7
4 changed files with 543 additions and 200 deletions
|
|
@ -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 = "<skip>"
|
||||
|
||||
"ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и "
|
||||
"«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или "
|
||||
"перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме "
|
||||
"(факт, совет, действие) без поддакиваний.\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()
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ docker compose down
|
|||
- `chat_history.sqlite3`
|
||||
- `polychaetsi_stats.json`
|
||||
- `bets.sqlite3`
|
||||
- `nude_history.json`
|
||||
|
||||
## Команды
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
24
main.py
24
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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue