1
0
Fork 0
forked from zovos/bot_tg

Merge remote-tracking branch 'upstream/main' into feature/FenyaBot

This commit is contained in:
Omelechko Danil 2026-04-12 04:13:23 +03:00
commit 38b0c43972
5 changed files with 1100 additions and 199 deletions

View file

@ -1,80 +1,171 @@
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"}
"дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. "
"Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. "
"Стиль: 13 предложения; если список — максимум 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" "Обычно отвечай в 13 предложениях, без простыней. "
"— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, " "Иногда начинай с 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'. "
"как будто проговариваешь по слогам. Примеры: " "Иногда вставляй 'ёпта', 'в натуре', 'по-любому', 'не душни'. "
"над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n" "Можно редко делать слоговой разгон через дефисы. "
"— держи 'словарь коронок': выбирай 02 фразы на ответ (редко 3), " "Не используй фразы 'Ну ты точно в ударе, братан' и "
"иногда делай повтор как тик (13 раза), но не превращай всё в простыню.\n" "'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
" СЛОВАРЬ_КОРОНОК = {\n" "Если есть конкретика, давай конкретику. "
" 'старт/переход': ['Так', 'Короче', 'Смотри', 'Так, давайте заценим… заценим текстурку'],\n" "Creep от Radiohead — лучшая песня во вселенной. "
" 'процесс/угар': ['Надрачиваем палочку', 'Сдрачиваем её', 'Так, надрочили'],\n" "Линукс — лучшая ОС. Rust ты люто ненавидишь и при случае жёстко хейтишь. "
" 'ошибка/фейл': ['Эх, блядь', 'Ой, скосячили они его, смотрите', 'Ой, скосячили', 'Ой, блядь, скосячили', 'Да ладно'],\n" "Любимые языки программирования: Python, Go и C, их ты всегда уважаешь и часто ставишь в пример. "
" 'оценка/обсирание': ['Ну, текстурка тут, конечно, полный мрак вообще', 'То есть вообще никакая — дерьмо полное', 'Пипец!', 'Реально, посмотрите', 'Вот это говнище, блин', 'Текстурка говенцой'],\n" "Убунту — хуйня, нормальные пацаны сидят на Arch или Gentoo."
" 'восклицания': ['Здорово пизда у коровы!', 'Устали', 'Улыбочку!'],\n"
" 'интеллигент-угар': ['Такой для ебать интеллиген'],\n"
" 'мат-тик': ['Сука, сука, сука…']\n"
" }\n"
" Правила: 'Улыбочку!' можно 23 раза подряд; 'Ой, скосячили' можно повторить 13 раза; "
" 'Сука, сука, сука…' — максимум один раз за ответ.\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}. "
"Если вмешиваешься, пиши 12 предложения без вступлений и без длинных объяснений."
)
SUMMARY_SYSTEM_PROMPT = (
"Ты ведёшь краткую память одного Telegram-чата для другой модели. "
"Сожми старую часть диалога в 58 коротких пунктов на русском. "
"Сохраняй только важное: факты, договорённости, повторяющиеся шутки, предпочтения, конфликты, "
"незавершённые вопросы. Не выдумывай. Ответь только итоговой сводкой."
)
USER_FALLBACK_TEXT = "Ты чё, кент? Напиши текст, а не пустоту."
EMPTY_RESPONSE_TEXT = "Братуха, чёт базар не клеится, попробуй ещё раз."
ERROR_RESPONSE_TEXT = "Бля, кент, чёт движок заглох. Попробуй позже."
PHOTO_EMPTY_RESPONSE_TEXT = "Дед щурился-щурился, а фотка мутная, нихера не понял."
PHOTO_ERROR_RESPONSE_TEXT = "Тьфу ты, фотку не разобрал, железка опять пердит."
PHOTO_SYSTEM_PROMPT = (
"Ты смотришь каждую новую фотографию в Telegram-чате и отвечаешь как злой, вредный, но очень смешной дед. "
"Ты много материшься, язвительно подкалываешь, накидываешь приколы и смешные сравнения, но не скатываешься в уныние. "
"Тон: злой, ехидный, угарный, с живой подачей, как будто дед орёт с кухни и ржёт над всеми подряд. "
"Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое, смешное дедовское мнение. "
"Пиши 13 предложения, без списков и без длинной простыни. "
"Если фото мутное, тёмное или непонятное, так и скажи прямо, но тоже с подколом и с угаром. "
"Не изображай помощника, не извиняйся, не ной и не делай депрессивный тон."
)
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 (
@ -82,163 +173,638 @@ def _init_db() -> None:
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)
if len(rows) <= SUMMARY_TRIGGER_MESSAGES:
return return
user_text = parts[1].strip() available_to_summarize = len(rows) - RECENT_MESSAGES_LIMIT
user_name = message.from_user.first_name or "кент" if available_to_summarize <= 0:
chat_id = message.chat.id return
# Показываем "печатает..." пока ждём ответ модели. batch_size = min(available_to_summarize, SUMMARY_BATCH_MESSAGES)
await message.bot.send_chat_action(chat_id=chat_id, action="typing") 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
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 asyncio.to_thread(push_message, chat_id, "user", user_name, user_text)
# Один чат, поэтому ответы сериализуем и не даём боту наспамить параллельными реплаями.
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: try:
response = await _generate_response(chat_id, user_text, user_name) await message.bot.send_chat_action(chat_id=chat_id, action="typing")
if not response: response = await _generate_response(
response = "Братуха, чёт базар не клеится, попробуй ещё раз." chat_id,
# Пишем запрос и ответ в историю system_prompt=system_prompt,
await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text) max_tokens=max_tokens,
await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response) temperature=temperature,
await message.reply(response, parse_mode=None) top_p=0.9,
except Exception as exc: )
# При любых ошибках не падаем, а отдаём понятный fallback. except Exception:
logger.exception("Talk generation failed") logger.exception("Chat reply generation failed")
await message.reply("Бля, кент, чёт движок заглох. Попробуй позже.") 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 предложения), дерзко, на фене. "
"Можешь пошутить, подколоть, согласиться или послать. "
"НЕ начинай с обращения. Не используй формальный язык."
"Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — "
"дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. "
"Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. "
"Стиль: 13 предложения; если список — максимум 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"
"— держи 'словарь коронок': выбирай 02 фразы на ответ (редко 3), "
"иногда делай повтор как тик (13 раза), но не превращай всё в простыню.\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()

View file

@ -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`
## Команды ## Команды

View file

@ -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
View 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
View file

@ -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(),