forked from zovos/bot_tg
Compare commits
20 commits
298a5d7d59
...
38b0c43972
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38b0c43972 | ||
|
|
a33443936c | ||
|
|
a2b72b6dfd | ||
|
|
d3ee72bca9 | ||
|
|
c756acf428 | ||
|
|
cec0256309 | ||
|
|
49957d88a8 | ||
|
|
b2556b06a9 | ||
|
|
5e91def067 | ||
|
|
924448b4e0 | ||
|
|
597839bb55 | ||
|
|
4d0a3841b7 | ||
|
|
9f8f767ae5 | ||
|
|
eeed9af545 | ||
|
|
53b6fa72ae | ||
|
|
7afd8ca216 | ||
| 06d97cbff0 | |||
|
|
6433d9c11e | ||
| 361e86c384 | |||
|
|
78dc7f4810 |
5 changed files with 1100 additions and 199 deletions
|
|
@ -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 = "<skip>"
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
319
games/nude.py
Normal file
319
games/nude.py
Normal file
|
|
@ -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 = (
|
||||
"🔥 <b>Справка по команде /nude:</b>\n\n"
|
||||
"Берет случайную фотку из последних постов сайта le saint des seins.\n"
|
||||
"• Источник: <code>https://lesaintdesseins.fr/</code>\n"
|
||||
"• Пагинацией тянет 1000+ последних постов через WordPress REST API\n"
|
||||
"• Хранит историю отправок и старается не повторять фотки\n"
|
||||
"• Выбирает случайную картинку и отправляет ее прямо в Telegram\n\n"
|
||||
"<i>Пример:</i> <code>/nude</code>"
|
||||
)
|
||||
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("Произошла ошибка при получении фото :(")
|
||||
51
main.py
51
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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue