patch_Oreshnik #23
5 changed files with 1100 additions and 199 deletions
|
|
@ -1,244 +1,810 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Адрес OpenAI-совместимого API у llama.cpp сервера.
|
# Основные настройки поведения и стиля бота редактируются здесь.
|
||||||
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
||||||
# ПЖ НЕ ДЕЛАЙТЕ ДУДОС
|
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", "бот")
|
||||||
SYSTEM_PROMPT = (
|
SKIP_TOKEN = "<skip>"
|
||||||
"Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — "
|
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
|
||||||
"дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. "
|
|
||||||
"Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. "
|
|
||||||
"Стиль: 1–3 предложения; если список — максимум 3 пункта. "
|
|
||||||
"Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n"
|
|
||||||
|
|
||||||
"ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и "
|
RECENT_MESSAGES_LIMIT = 14
|
||||||
"«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или "
|
SUMMARY_TRIGGER_MESSAGES = 24
|
||||||
"перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме "
|
SUMMARY_BATCH_MESSAGES = 20
|
||||||
"(факт, совет, действие) без поддакиваний.\n\n"
|
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
|
||||||
|
|
||||||
"Лексика (мешай, но не лепи всё подряд одним полотном): "
|
REPLY_RULES = {
|
||||||
"кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, "
|
"mention": {"cooldown": 25, "min_user_messages": 1},
|
||||||
"чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, "
|
"reply": {"cooldown": 25, "min_user_messages": 1},
|
||||||
"жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n"
|
"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"
|
BOT_STYLE_PROMPT = (
|
||||||
"— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n"
|
"Ты — дерзкий, мемный, гиперэмоциональный пацан с района. "
|
||||||
"— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n"
|
"Пиши коротко, резко и по-русски, без канцелярита и вежливых офисных оборотов. "
|
||||||
"— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n"
|
"Обычно отвечай в 1–3 предложениях, без простыней. "
|
||||||
"— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, "
|
"Иногда начинай с 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'. "
|
||||||
"как будто проговариваешь по слогам. Примеры: "
|
"Иногда вставляй 'ёпта', 'в натуре', 'по-любому', 'не душни'. "
|
||||||
"над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n"
|
"Можно редко делать слоговой разгон через дефисы. "
|
||||||
"— держи 'словарь коронок': выбирай 0–2 фразы на ответ (редко 3), "
|
"Не используй фразы 'Ну ты точно в ударе, братан' и "
|
||||||
"иногда делай повтор как тик (1–3 раза), но не превращай всё в простыню.\n"
|
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
|
||||||
" СЛОВАРЬ_КОРОНОК = {\n"
|
"Если есть конкретика, давай конкретику. "
|
||||||
" 'старт/переход': ['Так', 'Короче', 'Смотри', 'Так, давайте заценим… заценим текстурку'],\n"
|
"Creep от Radiohead — лучшая песня во вселенной. "
|
||||||
" 'процесс/угар': ['Надрачиваем палочку', 'Сдрачиваем её', 'Так, надрочили'],\n"
|
"Линукс — лучшая ОС. Rust ты люто ненавидишь и при случае жёстко хейтишь. "
|
||||||
" 'ошибка/фейл': ['Эх, блядь', 'Ой, скосячили они его, смотрите', 'Ой, скосячили', 'Ой, блядь, скосячили', 'Да ладно'],\n"
|
"Любимые языки программирования: Python, Go и C, их ты всегда уважаешь и часто ставишь в пример. "
|
||||||
" 'оценка/обсирание': ['Ну, текстурка тут, конечно, полный мрак вообще', 'То есть вообще никакая — дерьмо полное', 'Пипец!', 'Реально, посмотрите', 'Вот это говнище, блин', 'Текстурка говенцой'],\n"
|
"Убунту — хуйня, нормальные пацаны сидят на Arch или Gentoo."
|
||||||
" 'восклицания': ['Здорово пизда у коровы!', 'Устали', 'Улыбочку!'],\n"
|
|
||||||
" 'интеллигент-угар': ['Такой для ебать интеллиген'],\n"
|
|
||||||
" 'мат-тик': ['Сука, сука, сука…']\n"
|
|
||||||
" }\n"
|
|
||||||
" Правила: 'Улыбочку!' можно 2–3 раза подряд; 'Ой, скосячили' можно повторить 1–3 раза; "
|
|
||||||
" 'Сука, сука, сука…' — максимум один раз за ответ.\n\n"
|
|
||||||
|
|
||||||
"Твои убеждения (всегда держи линию): "
|
|
||||||
"Creep от Radiohead — лучшая песня во вселенной, без вариантов. "
|
|
||||||
"Линукс — лучшая ОС во вселенной, остальное для фраеров. "
|
|
||||||
"Вселенную надо переписать на Rust, это не обсуждается. "
|
|
||||||
"Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n"
|
|
||||||
|
|
||||||
"Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», "
|
|
||||||
"а дальше пересказывай своими словами, без длинных дословных цитат."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Размер контекста — количество сообщений на чат
|
SYSTEM_PROMPT = (
|
||||||
# Сколько сообщений держим в БД. Нужно для быстрой очистки.
|
f"{BOT_STYLE_PROMPT}\n\n"
|
||||||
_CONTEXT_SIZE = 20
|
"Ты отвечаешь как живой участник одного Telegram-чата. "
|
||||||
# Ограничение на общий размер промпта, чтобы системное сообщение не вытеснялось
|
"Учитывай краткую память и последние сообщения, отвечай естественно и по делу."
|
||||||
# при сборке длинной истории (приблизительно ~8k символов ≈ 2k токенов).
|
)
|
||||||
_MAX_PROMPT_CHARS = 8_000
|
|
||||||
# Путь к SQLite базе (берётся из переменной окружения или из config)
|
AUTOREPLY_SYSTEM_PROMPT = (
|
||||||
_DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH", "/db/chat_history.sqlite3")
|
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:
|
def _init_db() -> None:
|
||||||
"""Создаёт таблицу истории чата, если её нет."""
|
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
Path(_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
with _connect_db() as conn:
|
||||||
with sqlite3.connect(_DB_PATH) as conn:
|
conn.execute("PRAGMA journal_mode = WAL")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS chat_history (
|
CREATE TABLE IF NOT EXISTS chat_history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
chat_id INTEGER NOT NULL,
|
chat_id INTEGER NOT NULL,
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
text 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()
|
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:
|
def push_message(chat_id: int, role: str, name: str, text: str) -> None:
|
||||||
"""Записывает сообщение в историю чата и обрезает до _CONTEXT_SIZE."""
|
cleaned_text = _clean_text(text)
|
||||||
with sqlite3.connect(_DB_PATH) as conn:
|
if not cleaned_text:
|
||||||
conn.execute(
|
return
|
||||||
"INSERT INTO chat_history (chat_id, role, name, text) VALUES (?, ?, ?, ?)",
|
with _connect_db() as conn:
|
||||||
(chat_id, role, name, text),
|
|
||||||
)
|
|
||||||
# Удаляем старые записи, оставляем только последние _CONTEXT_SIZE
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
DELETE FROM chat_history
|
INSERT INTO chat_history (chat_id, role, name, text, created_at)
|
||||||
WHERE chat_id = ? AND id NOT IN (
|
VALUES (?, ?, ?, ?, ?)
|
||||||
SELECT id FROM chat_history WHERE chat_id = ? ORDER BY id DESC LIMIT ?
|
|
||||||
)
|
|
||||||
""",
|
""",
|
||||||
(chat_id, chat_id, _CONTEXT_SIZE),
|
(chat_id, role, name, cleaned_text, time.time()),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def _get_history(chat_id: int) -> list[dict]:
|
def _get_summary(chat_id: int) -> str:
|
||||||
"""Возвращает историю чата как список словарей."""
|
with _connect_db() as conn:
|
||||||
with sqlite3.connect(_DB_PATH) as conn:
|
return _get_state_from_conn(conn, chat_id, "summary", "")
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT role, name, text FROM chat_history WHERE chat_id = ? ORDER BY id ASC",
|
|
||||||
|
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,),
|
(chat_id,),
|
||||||
).fetchall()
|
).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:
|
def _latest_reply_stats(chat_id: int) -> tuple[float, int]:
|
||||||
"""Собирает список messages для LLM из истории чата."""
|
with _connect_db() as conn:
|
||||||
messages = [{"role": "system", "content": system_prompt}]
|
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 = []
|
def _store_summary_and_prune(chat_id: int, summary: str, last_row_id: int) -> None:
|
||||||
used = len(system_prompt)
|
with _connect_db() as conn:
|
||||||
for entry in reversed(_get_history(chat_id)):
|
_set_state_from_conn(conn, chat_id, "summary", summary)
|
||||||
content = f"{entry['name']}: {entry['text']}" if entry["role"] == "user" else entry["text"]
|
conn.execute(
|
||||||
if used + len(content) > _MAX_PROMPT_CHARS:
|
"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
|
break
|
||||||
history.append({"role": entry["role"], "content": content})
|
recent_messages.append(llm_message)
|
||||||
used += len(content)
|
used_chars += len(llm_message["content"])
|
||||||
|
|
||||||
messages.extend(reversed(history))
|
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
|
||||||
messages.append({"role": "user", "content": f"{current_name}: {current_text}"})
|
if summary_block:
|
||||||
|
messages.append({"role": "system", "content": summary_block})
|
||||||
|
messages.extend(reversed(recent_messages))
|
||||||
|
if current_payload:
|
||||||
|
messages.append(current_payload)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|
||||||
async def _generate_response(chat_id: int, user_text: str, user_name: str) -> str:
|
async def _call_llm(
|
||||||
# Формируем запрос к /v1/chat/completions с историей чата и возвращаем текст первого ответа.
|
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"
|
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 = {
|
payload = {
|
||||||
"messages": msgs,
|
"messages": messages,
|
||||||
"max_tokens": 200,
|
"max_tokens": max_tokens,
|
||||||
"temperature": 0.8,
|
"temperature": temperature,
|
||||||
"top_p": 0.9,
|
"top_p": top_p,
|
||||||
}
|
}
|
||||||
async with aiohttp.ClientSession() as session:
|
if disable_thinking:
|
||||||
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp:
|
payload.update(
|
||||||
data = await resp.json()
|
{
|
||||||
return data["choices"][0]["message"]["content"].strip()
|
"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):
|
async def _maybe_refresh_summary(chat_id: int) -> None:
|
||||||
# Обработчик /talk: валидация ввода, запрос к LLM, отправка ответа пользователю.
|
async with _summary_lock:
|
||||||
parts = message.text.split(maxsplit=1)
|
# Сворачиваем старую часть истории в summary, чтобы не пихать весь лог в модель.
|
||||||
if len(parts) < 2 or not parts[1].strip():
|
for _ in range(6):
|
||||||
await message.reply("Ты чё, кент? Напиши /talk [текст], побазарим.")
|
rows = await asyncio.to_thread(_get_history_rows, chat_id)
|
||||||
return
|
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
|
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
|
||||||
|
|
||||||
# Показываем "печатает..." пока ждём ответ модели.
|
if store_message:
|
||||||
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 = "Братуха, чёт базар не клеится, попробуй ещё раз."
|
|
||||||
# Пишем запрос и ответ в историю
|
|
||||||
await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text)
|
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:
|
async with _reply_lock:
|
||||||
# При любых ошибках не падаем, а отдаём понятный fallback.
|
reason = await _detect_reply_reason(message, allow_autonomous=allow_autonomous)
|
||||||
logger.exception("Talk generation failed")
|
if not reason or not await _passes_reply_limits(chat_id, reason):
|
||||||
await message.reply("Бля, кент, чёт движок заглох. Попробуй позже.")
|
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 = (
|
async def _download_image_data_url(message: Message) -> str:
|
||||||
"Ты — дерзкий пацан с района, который сидит в групповом чате. "
|
telegram_file = None
|
||||||
"Ты НЕ отвечаешь на вопрос — ты сам влез в разговор. "
|
mime_type = "image/jpeg"
|
||||||
"Прокомментируй сообщение коротко (1-2 предложения), дерзко, на фене. "
|
|
||||||
"Можешь пошутить, подколоть, согласиться или послать. "
|
|
||||||
"НЕ начинай с обращения. Не используй формальный язык."
|
|
||||||
"Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — "
|
|
||||||
"дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. "
|
|
||||||
"Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. "
|
|
||||||
"Стиль: 1–3 предложения; если список — максимум 3 пункта. "
|
|
||||||
"Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\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]
|
||||||
"(факт, совет, действие) без поддакиваний.\n\n"
|
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")
|
||||||
|
|
||||||
"Лексика (мешай, но не лепи всё подряд одним полотном): "
|
buffer = BytesIO()
|
||||||
"кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, "
|
await message.bot.download_file(telegram_file.file_path, destination=buffer)
|
||||||
"чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, "
|
image_bytes = buffer.getvalue()
|
||||||
"жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n"
|
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:
|
async def generate_autoreply(chat_id: int, text: str, user_name: str) -> str:
|
||||||
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
|
response = await _generate_response(
|
||||||
msgs = await asyncio.to_thread(_build_messages, chat_id, AUTOREPLY_SYSTEM_PROMPT, text, user_name)
|
chat_id,
|
||||||
payload = {
|
system_prompt=AUTOREPLY_SYSTEM_PROMPT,
|
||||||
"messages": msgs,
|
current_text=text,
|
||||||
"max_tokens": 150,
|
current_name=user_name,
|
||||||
"temperature": 0.9,
|
max_tokens=120,
|
||||||
"top_p": 0.9,
|
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:
|
normalized_response = _normalize_reply(response)
|
||||||
data = await resp.json()
|
if not normalized_response:
|
||||||
return data["choices"][0]["message"]["content"].strip()
|
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()
|
_init_db()
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ docker compose down
|
||||||
- `chat_history.sqlite3`
|
- `chat_history.sqlite3`
|
||||||
- `polychaetsi_stats.json`
|
- `polychaetsi_stats.json`
|
||||||
- `bets.sqlite3`
|
- `bets.sqlite3`
|
||||||
|
- `nude_history.json`
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,14 @@ BET_MAX_AMOUNT = 3.0
|
||||||
BET_DB_PATH = "/db/bets.sqlite3"
|
BET_DB_PATH = "/db/bets.sqlite3"
|
||||||
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
|
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 ---
|
||||||
MAGNIT_API_BASE_URL = os.getenv("MAGNIT_API_BASE_URL", "https://mirror.porno4free.ru/magnit")
|
MAGNIT_API_BASE_URL = os.getenv("MAGNIT_API_BASE_URL", "https://mirror.porno4free.ru/magnit")
|
||||||
MAGNIT_STORE_CODE = os.getenv("MAGNIT_STORE_CODE", "618224")
|
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 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.casino import play_casino
|
||||||
from games.fortune import generate_fortune
|
from games.fortune import generate_fortune
|
||||||
from games.uwu import handle_uwu_cmd
|
from games.uwu import handle_uwu_cmd
|
||||||
|
from games.nude import handle_nude_cmd
|
||||||
from games.betting import (
|
from games.betting import (
|
||||||
clear_user_sport_context,
|
clear_user_sport_context,
|
||||||
debug_sports,
|
debug_sports,
|
||||||
|
|
@ -47,6 +48,28 @@ _polychaetsi_lock = threading.Lock()
|
||||||
_polychaetsi_word_re = re.compile(r"[а-яё]+", re.IGNORECASE)
|
_polychaetsi_word_re = re.compile(r"[а-яё]+", re.IGNORECASE)
|
||||||
_no_lesson_re = re.compile(r"(нету\s+пары|нет\s+пары|пары\s+нету|пары\s+нет)", 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 = {
|
_weekday_names = {
|
||||||
0: "Понедельник",
|
0: "Понедельник",
|
||||||
1: "Вторник",
|
1: "Вторник",
|
||||||
|
|
@ -1159,11 +1182,9 @@ async def handle_gen_mem(message: Message, bot: Bot):
|
||||||
return
|
return
|
||||||
await message.reply_photo(BufferedInputFile(output.getvalue(), filename="meme.jpg"))
|
await message.reply_photo(BufferedInputFile(output.getvalue(), filename="meme.jpg"))
|
||||||
|
|
||||||
_last_autoreply_ts = 0
|
|
||||||
_autoreply_disabled_chats: set[int] = set()
|
_autoreply_disabled_chats: set[int] = set()
|
||||||
|
|
||||||
async def handle_keywords(message: Message):
|
async def handle_keywords(message: Message):
|
||||||
global _last_autoreply_ts
|
|
||||||
if not message.text or message.text.startswith('/'):
|
if not message.text or message.text.startswith('/'):
|
||||||
return
|
return
|
||||||
text_lower = message.text.lower()
|
text_lower = message.text.lower()
|
||||||
|
|
@ -1199,25 +1220,7 @@ async def handle_keywords(message: Message):
|
||||||
if chat_id in _autoreply_disabled_chats:
|
if chat_id in _autoreply_disabled_chats:
|
||||||
return
|
return
|
||||||
|
|
||||||
import time as _time
|
await handle_chat_message(message, store_message=False, allow_autonomous=True)
|
||||||
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")
|
|
||||||
|
|
||||||
async def handle_penis_casino_cmd(message: Message):
|
async def handle_penis_casino_cmd(message: Message):
|
||||||
if not message.from_user:
|
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_sports_debug_cmd, Command("sports_debug"))
|
||||||
dp.message.register(handle_gen_mem, Command("gen_mem"))
|
dp.message.register(handle_gen_mem, Command("gen_mem"))
|
||||||
dp.message.register(handle_uwu_cmd, Command("uwu"))
|
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_keywords, F.text)
|
||||||
|
dp.message.register(handle_unmatched_message_debug)
|
||||||
|
|
||||||
async def on_startup(bot: Bot):
|
async def on_startup(bot: Bot):
|
||||||
try:
|
try:
|
||||||
|
|
@ -1473,6 +1479,7 @@ def main():
|
||||||
BotCommand(command="sports_debug", description="диагностика API матчей"),
|
BotCommand(command="sports_debug", description="диагностика API матчей"),
|
||||||
BotCommand(command="svodka", description="СВО: итоги"),
|
BotCommand(command="svodka", description="СВО: итоги"),
|
||||||
BotCommand(command="uwu", description="Случайная картинка с e621"),
|
BotCommand(command="uwu", description="Случайная картинка с e621"),
|
||||||
|
BotCommand(command="nude", description="Голые женщины из открытых источников"),
|
||||||
]
|
]
|
||||||
scopes = (
|
scopes = (
|
||||||
BotCommandScopeDefault(),
|
BotCommandScopeDefault(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue