From 78dc7f4810359188969fbf7421effbbd7583c597 Mon Sep 17 00:00:00 2001 From: itexpert228 <67105314+fdaser1337@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:47:56 +0300 Subject: [PATCH 01/17] pisi i sisi --- games/nude.py | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++ main.py | 3 ++ 2 files changed, 87 insertions(+) create mode 100644 games/nude.py diff --git a/games/nude.py b/games/nude.py new file mode 100644 index 0000000..5dfe692 --- /dev/null +++ b/games/nude.py @@ -0,0 +1,84 @@ +import aiohttp +from aiogram import types +import config +import logging +import random +import json + +logger = logging.getLogger(__name__) + +CAPTIONS = [ + "Hot stuff 🔥", + "Sexy content 😏" +] + +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 = ( + "🔥 Справка по команде /nude:\n\n" + "Получает случайные изображения взрослых женщин из открытых источников.\n" + "• Использует Pornhub API для поиска контента\n" + "• По умолчанию ищет популярные видео с высоким рейтингом\n" + "• Отправляет превью видео как изображение\n\n" + "Пример: /nude\n" + "По умолчанию: Популярный контент для взрослых" + ) + await message.reply(help_msg, parse_mode="HTML") + return + + # Используем Pornhub API для поиска видео + url = "https://www.pornhub.com/webmasters/search" + + params = { + "category": "straight", + "ordering": "mostviewed", + "period": "week", + "thumbsize": "medium" + } + + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } + + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params, headers=headers) as resp: + if resp.status != 200: + logger.error(f"Pornhub API Error: {resp.status}") + await message.reply("Не удалось получить контент от сервера :(") + return + + # Pornhub API возвращает HTML, нужно парсить + text = await resp.text() + + # Ищем URL превью в HTML + import re + thumb_pattern = r']+src="([^"]+)"[^>]+class="thumb"' + matches = re.findall(thumb_pattern, text) + + if not matches: + await message.reply("Не удалось найти контент :(") + return + + # Берем случайное превью + thumb_url = random.choice(matches) + + # Убедимся что URL полный + if not thumb_url.startswith('http'): + thumb_url = f"https://thumb.pornhub.com{thumb_url}" + + caption = random.choice(CAPTIONS) + + # Отправляем как фото + try: + input_file = types.URLInputFile(thumb_url) + await message.answer_photo(photo=input_file, caption=caption) + except Exception as e: + logger.error(f"Error sending photo: {e}") + await message.answer(f"{caption}\n{thumb_url}") + + except Exception as e: + logger.exception("Error in nude command") + await message.reply("Произошла ошибка при обращении к API :(") diff --git a/main.py b/main.py index 7ee496c..7891bde 100644 --- a/main.py +++ b/main.py @@ -23,6 +23,7 @@ from AI.talk_handler import handle_talk, generate_autoreply, push_message from games.casino import play_casino from games.fortune import generate_fortune from games.uwu import handle_uwu_cmd +from games.nude import handle_nude_cmd from games.betting import ( clear_user_sport_context, debug_sports, @@ -1417,6 +1418,7 @@ def main(): dp.message.register(handle_sports_debug_cmd, Command("sports_debug")) dp.message.register(handle_gen_mem, Command("gen_mem")) dp.message.register(handle_uwu_cmd, Command("uwu")) + dp.message.register(handle_nude_cmd, Command("nude")) dp.message.register(handle_keywords, F.text) async def on_startup(bot: Bot): @@ -1446,6 +1448,7 @@ def main(): BotCommand(command="sports_debug", description="диагностика API матчей"), BotCommand(command="svodka", description="СВО: итоги"), BotCommand(command="uwu", description="Случайная картинка с e621"), + BotCommand(command="nude", description="Голые женщины из открытых источников"), ] scopes = ( BotCommandScopeDefault(), From 6433d9c11e4ec5f007e03d7150662aa95a4ddb0b Mon Sep 17 00:00:00 2001 From: itexpert228 <67105314+fdaser1337@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:56:19 +0300 Subject: [PATCH 02/17] suki --- games/nude.py | 104 +++++++++++++++++++++++++++++++------------------- 1 file changed, 64 insertions(+), 40 deletions(-) diff --git a/games/nude.py b/games/nude.py index 5dfe692..280bbf4 100644 --- a/games/nude.py +++ b/games/nude.py @@ -3,7 +3,7 @@ from aiogram import types import config import logging import random -import json +import re logger = logging.getLogger(__name__) @@ -19,66 +19,90 @@ async def handle_nude_cmd(message: types.Message): help_msg = ( "🔥 Справка по команде /nude:\n\n" "Получает случайные изображения взрослых женщин из открытых источников.\n" - "• Использует Pornhub API для поиска контента\n" - "• По умолчанию ищет популярные видео с высоким рейтингом\n" - "• Отправляет превью видео как изображение\n\n" + "• Использует прямое сканирование сайтов для поиска контента\n" + "• По умолчанию ищет популярные изображения\n" + "• Отправляет изображения напрямую\n\n" "Пример: /nude\n" - "По умолчанию: Популярный контент для взрослых" + "По умолчанию: Случайные изображения с популярных сайтов" ) await message.reply(help_msg, parse_mode="HTML") return - # Используем Pornhub API для поиска видео - url = "https://www.pornhub.com/webmasters/search" + # Список сайтов для сканирования + sites = [ + "https://www.imagefap.com/gallery.php?pg=random", + "https://xhamster.com/photos?sort=random", + "https://spankbang.com/random" + ] - params = { - "category": "straight", - "ordering": "mostviewed", - "period": "week", - "thumbsize": "medium" - } + random_site = random.choice(sites) headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1" } try: async with aiohttp.ClientSession() as session: - async with session.get(url, params=params, headers=headers) as resp: + async with session.get(random_site, headers=headers) as resp: if resp.status != 200: - logger.error(f"Pornhub API Error: {resp.status}") - await message.reply("Не удалось получить контент от сервера :(") + logger.error(f"Site access error: {resp.status}") + await message.reply("Не удалось получить доступ к сайту :(") return - # Pornhub API возвращает HTML, нужно парсить - text = await resp.text() + html = await resp.text() - # Ищем URL превью в HTML - import re - thumb_pattern = r']+src="([^"]+)"[^>]+class="thumb"' - matches = re.findall(thumb_pattern, text) + # Ищем URL изображений в HTML + img_patterns = [ + r']+src="([^"]+)"', + r']+href="([^"]+\.(jpg|jpeg|png|gif|webp))"', + r'data-src="([^"]+)"' + ] - if not matches: - await message.reply("Не удалось найти контент :(") + img_urls = [] + for pattern in img_patterns: + matches = re.findall(pattern, html, re.IGNORECASE) + img_urls.extend(matches) + + if not img_urls: + await message.reply("Изображения не найдены на странице :(") return - # Берем случайное превью - thumb_url = random.choice(matches) + # Фильтруем и выбираем случайное изображение + valid_imgs = [] + for img_url in img_urls: + if img_url.startswith('//'): + img_url = 'https:' + img_url + elif img_url.startswith('/'): + if 'imagefap.com' in random_site: + img_url = 'https://www.imagefap.com' + img_url + elif 'xhamster.com' in random_site: + img_url = 'https://xhamster.com' + img_url + elif 'spankbang.com' in random_site: + img_url = 'https://spankbang.com' + img_url + + if any(ext in img_url.lower() for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']): + valid_imgs.append(img_url) - # Убедимся что URL полный - if not thumb_url.startswith('http'): - thumb_url = f"https://thumb.pornhub.com{thumb_url}" + if not valid_imgs: + await message.reply("Подходящие изображения не найдены :(") + return - caption = random.choice(CAPTIONS) - - # Отправляем как фото - try: - input_file = types.URLInputFile(thumb_url) - await message.answer_photo(photo=input_file, caption=caption) - except Exception as e: - logger.error(f"Error sending photo: {e}") - await message.answer(f"{caption}\n{thumb_url}") + final_img_url = random.choice(valid_imgs) + caption = random.choice(CAPTIONS) + + # Отправляем изображение + try: + input_file = types.URLInputFile(final_img_url) + await message.answer_photo(photo=input_file, caption=caption) + except Exception as e: + logger.error(f"Error sending photo: {e}") + await message.answer(f"{caption}\n{final_img_url}") except Exception as e: logger.exception("Error in nude command") - await message.reply("Произошла ошибка при обращении к API :(") + await message.reply("Произошла ошибка при обработке запроса :(") From 7afd8ca21644fc36de8de6f641c25e12b5fa17d7 Mon Sep 17 00:00:00 2001 From: q Date: Fri, 10 Apr 2026 11:17:45 +0300 Subject: [PATCH 03/17] some fix for nude --- games/nude.py | 227 +++++++++++++++++++++++++++++++------------------- 1 file changed, 139 insertions(+), 88 deletions(-) diff --git a/games/nude.py b/games/nude.py index 280bbf4..71ac438 100644 --- a/games/nude.py +++ b/games/nude.py @@ -1,108 +1,159 @@ -import aiohttp -from aiogram import types -import config import logging import random -import re +from pathlib import PurePosixPath +from urllib.parse import urlparse + +import aiohttp +from aiogram import types + logger = logging.getLogger(__name__) CAPTIONS = [ "Hot stuff 🔥", - "Sexy content 😏" + "Sexy content 😏", ] +LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts" +LESAINT_REFERER = "https://lesaintdesseins.fr/" +LATEST_POSTS_LIMIT = 30 +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, +} + + +def _extract_photo_candidates(posts: list[dict]) -> list[dict]: + candidates = [] + + 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: + continue + + 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 + + +async def _fetch_latest_photo_candidates(session: aiohttp.ClientSession) -> list[dict]: + params = { + "per_page": str(LATEST_POSTS_LIMIT), + "orderby": "date", + "order": "desc", + "_embed": "wp:featuredmedia", + } + + async with session.get(LESAINT_POSTS_API, params=params, headers=REQUEST_HEADERS) as resp: + if resp.status != 200: + body = await resp.text() + raise RuntimeError(f"API returned {resp.status}: {body[:300]}") + + posts = await resp.json(content_type=None) + + if not isinstance(posts, list): + raise RuntimeError("Unexpected API response format") + + return _extract_photo_candidates(posts) + + +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 = ( "🔥 Справка по команде /nude:\n\n" - "Получает случайные изображения взрослых женщин из открытых источников.\n" - "• Использует прямое сканирование сайтов для поиска контента\n" - "• По умолчанию ищет популярные изображения\n" - "• Отправляет изображения напрямую\n\n" - "Пример: /nude\n" - "По умолчанию: Случайные изображения с популярных сайтов" + "Берет случайную фотку из последних постов сайта le saint des seins.\n" + "• Источник: https://lesaintdesseins.fr/\n" + "• Достает последние посты через WordPress REST API\n" + "• Выбирает случайную картинку и отправляет ее прямо в Telegram\n\n" + "Пример: /nude" ) await message.reply(help_msg, parse_mode="HTML") return - # Список сайтов для сканирования - sites = [ - "https://www.imagefap.com/gallery.php?pg=random", - "https://xhamster.com/photos?sort=random", - "https://spankbang.com/random" - ] - - random_site = random.choice(sites) - - headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "Accept-Language": "en-US,en;q=0.5", - "Accept-Encoding": "gzip, deflate", - "Connection": "keep-alive", - "Upgrade-Insecure-Requests": "1" - } - + timeout = aiohttp.ClientTimeout(total=30) + try: - async with aiohttp.ClientSession() as session: - async with session.get(random_site, headers=headers) as resp: - if resp.status != 200: - logger.error(f"Site access error: {resp.status}") - await message.reply("Не удалось получить доступ к сайту :(") - return - - html = await resp.text() - - # Ищем URL изображений в HTML - img_patterns = [ - r']+src="([^"]+)"', - r']+href="([^"]+\.(jpg|jpeg|png|gif|webp))"', - r'data-src="([^"]+)"' - ] - - img_urls = [] - for pattern in img_patterns: - matches = re.findall(pattern, html, re.IGNORECASE) - img_urls.extend(matches) - - if not img_urls: - await message.reply("Изображения не найдены на странице :(") - return - - # Фильтруем и выбираем случайное изображение - valid_imgs = [] - for img_url in img_urls: - if img_url.startswith('//'): - img_url = 'https:' + img_url - elif img_url.startswith('/'): - if 'imagefap.com' in random_site: - img_url = 'https://www.imagefap.com' + img_url - elif 'xhamster.com' in random_site: - img_url = 'https://xhamster.com' + img_url - elif 'spankbang.com' in random_site: - img_url = 'https://spankbang.com' + img_url - - if any(ext in img_url.lower() for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']): - valid_imgs.append(img_url) - - if not valid_imgs: - await message.reply("Подходящие изображения не найдены :(") - return - - final_img_url = random.choice(valid_imgs) - caption = random.choice(CAPTIONS) - - # Отправляем изображение - try: - input_file = types.URLInputFile(final_img_url) - await message.answer_photo(photo=input_file, caption=caption) - except Exception as e: - logger.error(f"Error sending photo: {e}") - await message.answer(f"{caption}\n{final_img_url}") - - except Exception as e: + async with aiohttp.ClientSession(timeout=timeout) as session: + candidates = await _fetch_latest_photo_candidates(session) + + if not candidates: + await message.reply("Не удалось найти свежие фотки :(") + return + + selected = random.choice(candidates) + 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("Произошла ошибка при обработке запроса :(") + await message.reply("Произошла ошибка при получении фото :(") From 53b6fa72ae9242d124ae73eb419228ba1b3df04c Mon Sep 17 00:00:00 2001 From: q Date: Fri, 10 Apr 2026 11:20:14 +0300 Subject: [PATCH 04/17] some fix for nude change word --- games/nude.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/games/nude.py b/games/nude.py index 71ac438..0e0a0fd 100644 --- a/games/nude.py +++ b/games/nude.py @@ -10,8 +10,9 @@ from aiogram import types logger = logging.getLogger(__name__) CAPTIONS = [ - "Hot stuff 🔥", - "Sexy content 😏", + "Шо ты блять псюнчик решил свой подергать?", + "Решил подрочить сука?", + "гнойный писюн у тебя кнч, да", ] LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts" From eeed9af5451f6de0e00a8559a5f7bf42082e04ee Mon Sep 17 00:00:00 2001 From: q Date: Fri, 10 Apr 2026 12:01:20 +0300 Subject: [PATCH 05/17] some fix for nude save db with history with pic --- games/nude.py | 145 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 136 insertions(+), 9 deletions(-) diff --git a/games/nude.py b/games/nude.py index 0e0a0fd..4c37767 100644 --- a/games/nude.py +++ b/games/nude.py @@ -1,3 +1,5 @@ +import asyncio +import json import logging import random from pathlib import PurePosixPath @@ -6,6 +8,8 @@ from urllib.parse import urlparse import aiohttp from aiogram import types +import config + logger = logging.getLogger(__name__) @@ -17,7 +21,6 @@ CAPTIONS = [ LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts" LESAINT_REFERER = "https://lesaintdesseins.fr/" -LATEST_POSTS_LIMIT = 30 REQUEST_HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " @@ -29,9 +32,12 @@ REQUEST_HEADERS = { "Referer": LESAINT_REFERER, } +_NUDE_HISTORY_LOCK = asyncio.Lock() + def _extract_photo_candidates(posts: list[dict]) -> list[dict]: candidates = [] + seen_urls = set() for post in posts: embedded = post.get("_embedded") or {} @@ -48,9 +54,10 @@ def _extract_photo_candidates(posts: list[dict]) -> list[dict]: or media.get("source_url") ) - if not image_url: + if not image_url or image_url in seen_urls: continue + seen_urls.add(image_url) candidates.append( { "image_url": image_url, @@ -73,25 +80,130 @@ def _guess_filename(image_url: str) -> str: return filename -async def _fetch_latest_photo_candidates(session: aiohttp.ClientSession) -> list[dict]: +def _load_nude_history() -> dict: + path = config.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[-config.NUDE_HISTORY_MAX_SIZE :]} + + +def _save_nude_history(data: dict) -> None: + path = config.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[-config.NUDE_HISTORY_MAX_SIZE :] + + +async def _fetch_posts_page( + session: aiohttp.ClientSession, page: int, per_page: int +) -> tuple[list[dict], int | None]: params = { - "per_page": str(LATEST_POSTS_LIMIT), + "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 _extract_photo_candidates(posts) + return posts, total_pages + + +async def _fetch_latest_photo_candidates( + session: aiohttp.ClientSession, sent_urls: set[str] +) -> list[dict]: + per_page = max(1, min(config.NUDE_POSTS_PER_PAGE, 100)) + max_scan = max(config.NUDE_POSTS_POOL_TARGET, config.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) >= config.NUDE_POSTS_POOL_TARGET and unseen_count >= config.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( @@ -120,24 +232,39 @@ async def handle_nude_cmd(message: types.Message): "🔥 Справка по команде /nude:\n\n" "Берет случайную фотку из последних постов сайта le saint des seins.\n" "• Источник: https://lesaintdesseins.fr/\n" - "• Достает последние посты через WordPress REST API\n" + "• Пагинацией тянет 1000+ последних постов через WordPress REST API\n" + "• Хранит историю отправок и старается не повторять фотки\n" "• Выбирает случайную картинку и отправляет ее прямо в Telegram\n\n" "Пример: /nude" ) await message.reply(help_msg, parse_mode="HTML") return - timeout = aiohttp.ClientTimeout(total=30) + 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) + candidates = await _fetch_latest_photo_candidates(session, sent_urls_snapshot) if not candidates: await message.reply("Не удалось найти свежие фотки :(") return - selected = random.choice(candidates) + 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"]) From 9f8f767ae5f43dc9734f6549cea3d65be6072c8a Mon Sep 17 00:00:00 2001 From: q Date: Fri, 10 Apr 2026 12:14:20 +0300 Subject: [PATCH 06/17] some fix for nude save db with --- games/nude.py | 48 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/games/nude.py b/games/nude.py index 4c37767..46e4c36 100644 --- a/games/nude.py +++ b/games/nude.py @@ -1,8 +1,9 @@ import asyncio import json import logging +import os import random -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath from urllib.parse import urlparse import aiohttp @@ -35,6 +36,37 @@ REQUEST_HEADERS = { _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() @@ -81,7 +113,7 @@ def _guess_filename(image_url: str) -> str: def _load_nude_history() -> dict: - path = config.NUDE_HISTORY_PATH + path = NUDE_HISTORY_PATH if not path.exists(): return {"sent_images": []} @@ -100,11 +132,11 @@ def _load_nude_history() -> dict: sent_images = [] cleaned = [item for item in sent_images if isinstance(item, str) and item.strip()] - return {"sent_images": cleaned[-config.NUDE_HISTORY_MAX_SIZE :]} + return {"sent_images": cleaned[-NUDE_HISTORY_MAX_SIZE :]} def _save_nude_history(data: dict) -> None: - path = config.NUDE_HISTORY_PATH + 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: @@ -119,7 +151,7 @@ def _remember_sent_image(history: dict, image_url: str) -> None: sent_images = [item for item in sent_images if item != image_url] sent_images.append(image_url) - history["sent_images"] = sent_images[-config.NUDE_HISTORY_MAX_SIZE :] + history["sent_images"] = sent_images[-NUDE_HISTORY_MAX_SIZE :] async def _fetch_posts_page( @@ -161,8 +193,8 @@ async def _fetch_posts_page( async def _fetch_latest_photo_candidates( session: aiohttp.ClientSession, sent_urls: set[str] ) -> list[dict]: - per_page = max(1, min(config.NUDE_POSTS_PER_PAGE, 100)) - max_scan = max(config.NUDE_POSTS_POOL_TARGET, config.NUDE_MAX_POSTS_TO_SCAN) + 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 = [] @@ -191,7 +223,7 @@ async def _fetch_latest_photo_candidates( if image_url not in sent_urls: unseen_count += 1 - if len(candidates) >= config.NUDE_POSTS_POOL_TARGET and unseen_count >= config.NUDE_MIN_UNSEEN_POOL: + if len(candidates) >= NUDE_POSTS_POOL_TARGET and unseen_count >= NUDE_MIN_UNSEEN_POOL: break if len(candidates) >= max_scan: break From 4d0a3841b7ed03ad558be44e377c7b7a5ab87155 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 16:15:14 +0300 Subject: [PATCH 07/17] update ai dolboeba --- AI/talk_handler.py | 710 +++++++++++++++++++++++++++++++++------------ README.md | 1 + config.py | 8 + main.py | 24 +- 4 files changed, 543 insertions(+), 200 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 2758c97..6139bd7 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -1,7 +1,12 @@ +from __future__ import annotations + import asyncio -import os import logging +import os +import random +import re import sqlite3 +import time from pathlib import Path import aiohttp @@ -9,236 +14,585 @@ from aiogram.types import Message logger = logging.getLogger(__name__) -# Адрес OpenAI-совместимого API у llama.cpp сервера. +# Основные настройки поведения и стиля бота редактируются здесь. LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/") -# ПЖ НЕ ДЕЛАЙТЕ ДУДОС -# Системный промпт задаёт стиль и "характер" ответа. -SYSTEM_PROMPT = ( - "Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — " - "дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. " - "Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. " - "Стиль: 1–3 предложения; если список — максимум 3 пункта. " - "Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n" +DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or str(Path(__file__).resolve().with_name("chat_history.sqlite3")) +BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот") +SKIP_TOKEN = "" - "ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и " - "«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или " - "перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме " - "(факт, совет, действие) без поддакиваний.\n\n" +RECENT_MESSAGES_LIMIT = 14 +SUMMARY_TRIGGER_MESSAGES = 24 +SUMMARY_BATCH_MESSAGES = 20 +PROMPT_CHAR_BUDGET = 6_500 +SUMMARY_CHAR_BUDGET = 1_800 +MAX_INPUT_CHARS = 2_000 +SUMMARY_LINE_CHAR_LIMIT = 220 +HISTORY_LINE_CHAR_LIMIT = 450 - "Лексика (мешай, но не лепи всё подряд одним полотном): " - "кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, " - "чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, " - "жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n" +REPLY_RULES = { + "mention": {"cooldown": 25, "min_user_messages": 1}, + "reply": {"cooldown": 25, "min_user_messages": 1}, + "question": {"cooldown": 45, "min_user_messages": 1}, + "autonomous": {"cooldown": 15 * 60, "min_user_messages": 6}, +} +AUTONOMOUS_REPLY_CHANCE = 0.07 +AUTONOMOUS_MIN_TEXT_LEN = 18 - "Фирменные приёмы речи:\n" - "— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n" - "— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n" - "— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n" - "— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, " - "как будто проговариваешь по слогам. Примеры: " - "над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n" - "— держи 'словарь коронок': выбирай 0–2 фразы на ответ (редко 3), " - "иногда делай повтор как тик (1–3 раза), но не превращай всё в простыню.\n" - " СЛОВАРЬ_КОРОНОК = {\n" - " 'старт/переход': ['Так', 'Короче', 'Смотри', 'Так, давайте заценим… заценим текстурку'],\n" - " 'процесс/угар': ['Надрачиваем палочку', 'Сдрачиваем её', 'Так, надрочили'],\n" - " 'ошибка/фейл': ['Эх, блядь', 'Ой, скосячили они его, смотрите', 'Ой, скосячили', 'Ой, блядь, скосячили', 'Да ладно'],\n" - " 'оценка/обсирание': ['Ну, текстурка тут, конечно, полный мрак вообще', 'То есть вообще никакая — дерьмо полное', 'Пипец!', 'Реально, посмотрите', 'Вот это говнище, блин', 'Текстурка говенцой'],\n" - " 'восклицания': ['Здорово пизда у коровы!', 'Устали', 'Улыбочку!'],\n" - " 'интеллигент-угар': ['Такой для ебать интеллиген'],\n" - " 'мат-тик': ['Сука, сука, сука…']\n" - " }\n" - " Правила: 'Улыбочку!' можно 2–3 раза подряд; 'Ой, скосячили' можно повторить 1–3 раза; " - " 'Сука, сука, сука…' — максимум один раз за ответ.\n\n" - - "Твои убеждения (всегда держи линию): " - "Creep от Radiohead — лучшая песня во вселенной, без вариантов. " - "Линукс — лучшая ОС во вселенной, остальное для фраеров. " - "Вселенную надо переписать на Rust, это не обсуждается. " - "Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n" - - "Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», " - "а дальше пересказывай своими словами, без длинных дословных цитат." +BOT_STYLE_PROMPT = ( + "Ты — дерзкий, мемный, гиперэмоциональный пацан с района. " + "Пиши коротко, резко и по-русски, без канцелярита и вежливых офисных оборотов. " + "Обычно отвечай в 1–3 предложениях, без простыней. " + "Иногда начинай с 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'. " + "Иногда вставляй 'ёпта', 'в натуре', 'по-любому', 'не душни'. " + "Можно редко делать слоговой разгон через дефисы. " + "Не используй фразы 'Ну ты точно в ударе, братан' и " + "'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. " + "Если есть конкретика, давай конкретику. " + "Creep от Radiohead — лучшая песня во вселенной. " + "Линукс — лучшая ОС. Вселенную надо переписать на Rust. " + "Убунту — хуйня, нормальные пацаны сидят на Arch или Gentoo." ) -# Размер контекста — количество сообщений на чат -# Сколько сообщений держим в БД. Нужно для быстрой очистки. -_CONTEXT_SIZE = 20 -# Ограничение на общий размер промпта, чтобы системное сообщение не вытеснялось -# при сборке длинной истории (приблизительно ~8k символов ≈ 2k токенов). -_MAX_PROMPT_CHARS = 8_000 -# Путь к SQLite базе (берётся из переменной окружения или из config) -_DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH", "/db/chat_history.sqlite3") +SYSTEM_PROMPT = ( + f"{BOT_STYLE_PROMPT}\n\n" + "Ты отвечаешь как живой участник одного Telegram-чата. " + "Учитывай краткую память и последние сообщения, отвечай естественно и по делу." +) + +AUTOREPLY_SYSTEM_PROMPT = ( + f"{BOT_STYLE_PROMPT}\n\n" + "Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. " + f"Если лучше промолчать или добавить нечего, ответь ровно {SKIP_TOKEN}. " + "Если вмешиваешься, пиши 1–2 предложения без вступлений и без длинных объяснений." +) + +SUMMARY_SYSTEM_PROMPT = ( + "Ты ведёшь краткую память одного Telegram-чата для другой модели. " + "Сожми старую часть диалога в 5–8 коротких пунктов на русском. " + "Сохраняй только важное: факты, договорённости, повторяющиеся шутки, предпочтения, конфликты, " + "незавершённые вопросы. Не выдумывай. Ответь только итоговой сводкой." +) + +USER_FALLBACK_TEXT = "Ты чё, кент? Напиши текст, а не пустоту." +EMPTY_RESPONSE_TEXT = "Братуха, чёт базар не клеится, попробуй ещё раз." +ERROR_RESPONSE_TEXT = "Бля, кент, чёт движок заглох. Попробуй позже." + +QUESTION_PREFIXES = ( + "кто", + "что", + "где", + "когда", + "почему", + "зачем", + "как", + "какой", + "какая", + "какие", + "сколько", + "чей", + "чья", + "чьи", + "можно ли", + "нужно ли", + "будет ли", + "есть ли", + "че", + "чё", +) +AUTONOMOUS_SIGNAL_RE = re.compile( + r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b", + re.IGNORECASE, +) +LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]") + +_reply_lock = asyncio.Lock() +_summary_lock = asyncio.Lock() +_cached_bot_id: int | None = None +_cached_bot_username: str = "" + + +def _connect_db() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout = 30000") + return conn + + +def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + return any(row["name"] == column for row in rows) + + +def _get_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, default: str = "") -> str: + row = conn.execute( + "SELECT value FROM chat_state WHERE chat_id = ? AND key = ?", + (chat_id, key), + ).fetchone() + return row["value"] if row else default + + +def _set_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, value: str) -> None: + conn.execute( + """ + INSERT INTO chat_state (chat_id, key, value) + VALUES (?, ?, ?) + ON CONFLICT(chat_id, key) DO UPDATE SET value = excluded.value + """, + (chat_id, key, value), + ) def _init_db() -> None: - """Создаёт таблицу истории чата, если её нет.""" - Path(_DB_PATH).parent.mkdir(parents=True, exist_ok=True) - with sqlite3.connect(_DB_PATH) as conn: + Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) + with _connect_db() as conn: + conn.execute("PRAGMA journal_mode = WAL") conn.execute( """ CREATE TABLE IF NOT EXISTS chat_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - chat_id INTEGER NOT NULL, - role TEXT NOT NULL, - name TEXT NOT NULL, - text TEXT NOT NULL + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL, + role TEXT NOT NULL, + name TEXT NOT NULL, + text TEXT NOT NULL, + created_at REAL NOT NULL DEFAULT 0 ) """ ) - conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id ON chat_history(chat_id, id)") + if not _column_exists(conn, "chat_history", "created_at"): + conn.execute("ALTER TABLE chat_history ADD COLUMN created_at REAL NOT NULL DEFAULT 0") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS chat_state ( + chat_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (chat_id, key) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)" + ) conn.commit() +def _clean_text(text: str) -> str: + return (text or "").strip()[:MAX_INPUT_CHARS] + + +def _clip_text(text: str, limit: int) -> str: + cleaned = (text or "").strip() + if len(cleaned) <= limit: + return cleaned + return f"{cleaned[: max(0, limit - 1)].rstrip()}…" + + def push_message(chat_id: int, role: str, name: str, text: str) -> None: - """Записывает сообщение в историю чата и обрезает до _CONTEXT_SIZE.""" - with sqlite3.connect(_DB_PATH) as conn: - conn.execute( - "INSERT INTO chat_history (chat_id, role, name, text) VALUES (?, ?, ?, ?)", - (chat_id, role, name, text), - ) - # Удаляем старые записи, оставляем только последние _CONTEXT_SIZE + cleaned_text = _clean_text(text) + if not cleaned_text: + return + with _connect_db() as conn: conn.execute( """ - DELETE FROM chat_history - WHERE chat_id = ? AND id NOT IN ( - SELECT id FROM chat_history WHERE chat_id = ? ORDER BY id DESC LIMIT ? - ) + INSERT INTO chat_history (chat_id, role, name, text, created_at) + VALUES (?, ?, ?, ?, ?) """, - (chat_id, chat_id, _CONTEXT_SIZE), + (chat_id, role, name, cleaned_text, time.time()), ) conn.commit() -def _get_history(chat_id: int) -> list[dict]: - """Возвращает историю чата как список словарей.""" - with sqlite3.connect(_DB_PATH) as conn: - rows = conn.execute( - "SELECT role, name, text FROM chat_history WHERE chat_id = ? ORDER BY id ASC", +def _get_summary(chat_id: int) -> str: + with _connect_db() as conn: + return _get_state_from_conn(conn, chat_id, "summary", "") + + +def _get_history_rows(chat_id: int) -> list[sqlite3.Row]: + with _connect_db() as conn: + return conn.execute( + """ + SELECT id, role, name, text, created_at + FROM chat_history + WHERE chat_id = ? + ORDER BY id ASC + """, (chat_id,), ).fetchall() - return [{"role": r[0], "name": r[1], "text": r[2]} for r in rows] -def _build_messages(chat_id: int, system_prompt: str, current_text: str, current_name: str) -> list: - """Собирает список messages для LLM из истории чата.""" - messages = [{"role": "system", "content": system_prompt}] +def _latest_reply_stats(chat_id: int) -> tuple[float, int]: + with _connect_db() as conn: + last_assistant = conn.execute( + """ + SELECT id, created_at + FROM chat_history + WHERE chat_id = ? AND role = 'assistant' + ORDER BY id DESC + LIMIT 1 + """, + (chat_id,), + ).fetchone() + if not last_assistant: + return 0.0, 10_000 + user_messages_since_reply = conn.execute( + """ + SELECT COUNT(*) + FROM chat_history + WHERE chat_id = ? AND role = 'user' AND id > ? + """, + (chat_id, last_assistant["id"]), + ).fetchone()[0] + return float(last_assistant["created_at"] or 0.0), int(user_messages_since_reply) - # Берём историю с конца, пока не превысим бюджет по символам. - history = [] - used = len(system_prompt) - for entry in reversed(_get_history(chat_id)): - content = f"{entry['name']}: {entry['text']}" if entry["role"] == "user" else entry["text"] - if used + len(content) > _MAX_PROMPT_CHARS: + +def _store_summary_and_prune(chat_id: int, summary: str, last_row_id: int) -> None: + with _connect_db() as conn: + _set_state_from_conn(conn, chat_id, "summary", summary) + conn.execute( + "DELETE FROM chat_history WHERE chat_id = ? AND id <= ?", + (chat_id, last_row_id), + ) + conn.commit() + + +def _format_row_for_llm(row: sqlite3.Row) -> dict[str, str]: + if row["role"] == "user": + return { + "role": "user", + "content": f"{row['name']}: {_clip_text(row['text'], HISTORY_LINE_CHAR_LIMIT)}", + } + return {"role": "assistant", "content": _clip_text(row["text"], HISTORY_LINE_CHAR_LIMIT)} + + +def _build_messages( + chat_id: int, + system_prompt: str, + current_text: str | None = None, + current_name: str | None = None, +) -> list[dict[str, str]]: + current_payload = None + current_budget = 0 + if current_text: + current_payload = { + "role": "user", + "content": f"{current_name or 'кент'}: {_clean_text(current_text)}", + } + current_budget = len(current_payload["content"]) + + summary = _get_summary(chat_id).strip() + summary_block = "" + if summary: + summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" + + used_chars = len(system_prompt) + len(summary_block) + current_budget + recent_messages: list[dict[str, str]] = [] + for row in reversed(_get_history_rows(chat_id)[-RECENT_MESSAGES_LIMIT:]): + llm_message = _format_row_for_llm(row) + if used_chars + len(llm_message["content"]) > PROMPT_CHAR_BUDGET: break - history.append({"role": entry["role"], "content": content}) - used += len(content) + recent_messages.append(llm_message) + used_chars += len(llm_message["content"]) - messages.extend(reversed(history)) - messages.append({"role": "user", "content": f"{current_name}: {current_text}"}) + messages = [{"role": "system", "content": system_prompt}] + if summary_block: + messages.append({"role": "system", "content": summary_block}) + messages.extend(reversed(recent_messages)) + if current_payload: + messages.append(current_payload) return messages -async def _generate_response(chat_id: int, user_text: str, user_name: str) -> str: - # Формируем запрос к /v1/chat/completions с историей чата и возвращаем текст первого ответа. +async def _call_llm( + messages: list[dict[str, str]], + *, + max_tokens: int, + temperature: float, + top_p: float, +) -> str: url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions" - msgs = await asyncio.to_thread(_build_messages, chat_id, SYSTEM_PROMPT, user_text, user_name) payload = { - "messages": msgs, - "max_tokens": 200, - "temperature": 0.8, - "top_p": 0.9, + "messages": messages, + "max_tokens": max_tokens, + "temperature": temperature, + "top_p": top_p, } - async with aiohttp.ClientSession() as session: - async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp: - data = await resp.json() - return data["choices"][0]["message"]["content"].strip() + timeout = aiohttp.ClientTimeout(total=120) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=payload) as resp: + raw_text = await resp.text() + if resp.status >= 400: + raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}") + try: + data = await resp.json(content_type=None) + except Exception as exc: + raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc + + choices = data.get("choices") or [] + if not choices: + raise RuntimeError(f"LLM API returned no choices: {data}") + content = choices[0].get("message", {}).get("content", "") + return content.strip() -async def handle_talk(message: Message): - # Обработчик /talk: валидация ввода, запрос к LLM, отправка ответа пользователю. - parts = message.text.split(maxsplit=1) - if len(parts) < 2 or not parts[1].strip(): - await message.reply("Ты чё, кент? Напиши /talk [текст], побазарим.") - return +async def _maybe_refresh_summary(chat_id: int) -> None: + async with _summary_lock: + # Сворачиваем старую часть истории в summary, чтобы не пихать весь лог в модель. + for _ in range(6): + rows = await asyncio.to_thread(_get_history_rows, chat_id) + if len(rows) <= SUMMARY_TRIGGER_MESSAGES: + return + + available_to_summarize = len(rows) - RECENT_MESSAGES_LIMIT + if available_to_summarize <= 0: + return + + batch_size = min(available_to_summarize, SUMMARY_BATCH_MESSAGES) + rows_to_summarize = rows[:batch_size] + current_summary = await asyncio.to_thread(_get_summary, chat_id) + transcript = "\n".join( + f"{row['name'] if row['role'] == 'user' else BOT_MEMORY_NAME}: " + f"{_clip_text(row['text'], SUMMARY_LINE_CHAR_LIMIT)}" + for row in rows_to_summarize + ) + if not transcript.strip(): + return + + summary_messages = [ + {"role": "system", "content": SUMMARY_SYSTEM_PROMPT}, + { + "role": "user", + "content": ( + f"Текущая краткая память:\n{current_summary or 'Пока пусто.'}\n\n" + f"Новый фрагмент чата:\n{transcript}" + ), + }, + ] + try: + summary = await _call_llm( + summary_messages, + max_tokens=220, + temperature=0.2, + top_p=0.9, + ) + except Exception: + logger.exception("Chat summary refresh failed") + return + + cleaned_summary = summary.strip() + if not cleaned_summary: + return + last_row_id = int(rows_to_summarize[-1]["id"]) + await asyncio.to_thread(_store_summary_and_prune, chat_id, cleaned_summary, last_row_id) + + +async def _generate_response( + chat_id: int, + *, + system_prompt: str, + current_text: str | None = None, + current_name: str | None = None, + max_tokens: int, + temperature: float, + top_p: float, +) -> str: + await _maybe_refresh_summary(chat_id) + messages = await asyncio.to_thread( + _build_messages, + chat_id, + system_prompt, + current_text, + current_name, + ) + return await _call_llm( + messages, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + ) + + +async def _get_bot_identity(message: Message) -> tuple[int | None, str]: + global _cached_bot_id, _cached_bot_username + if _cached_bot_id is None: + me = await message.bot.get_me() + _cached_bot_id = me.id + _cached_bot_username = (me.username or "").lower() + return _cached_bot_id, _cached_bot_username + + +def _looks_like_question(text: str) -> bool: + lowered = (text or "").strip().lower() + if not lowered: + return False + if "?" in lowered or "?" in lowered: + return True + normalized = re.sub(r"^[^a-zа-яё0-9]+", "", lowered) + return any(normalized == prefix or normalized.startswith(f"{prefix} ") for prefix in QUESTION_PREFIXES) + + +def _is_reply_to_bot(message: Message, bot_id: int | None) -> bool: + reply = message.reply_to_message + return bool(bot_id and reply and reply.from_user and reply.from_user.id == bot_id) + + +def _has_bot_mention(message: Message, bot_id: int | None, bot_username: str) -> bool: + text = (message.text or "").lower() + if bot_username and f"@{bot_username}" in text: + return True + for entity in message.entities or []: + entity_type = getattr(entity.type, "value", entity.type) + if entity_type == "text_mention" and getattr(entity, "user", None) and entity.user.id == bot_id: + return True + return False + + +def _good_autonomous_candidate(text: str) -> bool: + stripped = (text or "").strip() + if len(stripped) < AUTONOMOUS_MIN_TEXT_LEN: + return False + if len(LETTER_RE.findall(stripped)) < 10: + return False + if AUTONOMOUS_SIGNAL_RE.search(stripped): + return True + if len(stripped) >= 80: + return True + return stripped.count("!") >= 2 or "..." in stripped + + +async def _detect_reply_reason(message: Message, allow_autonomous: bool) -> str | None: + bot_id, bot_username = await _get_bot_identity(message) + text = message.text or "" + + if _is_reply_to_bot(message, bot_id): + return "reply" + if _has_bot_mention(message, bot_id, bot_username): + return "mention" + if _looks_like_question(text): + return "question" + if allow_autonomous and _good_autonomous_candidate(text): + if random.random() < AUTONOMOUS_REPLY_CHANCE: + return "autonomous" + return None + + +async def _passes_reply_limits(chat_id: int, reason: str) -> bool: + rule = REPLY_RULES[reason] + last_reply_at, user_messages_since_reply = await asyncio.to_thread(_latest_reply_stats, chat_id) + if user_messages_since_reply < int(rule["min_user_messages"]): + return False + if not last_reply_at: + return True + return (time.time() - last_reply_at) >= int(rule["cooldown"]) + + +def _normalize_reply(text: str) -> str: + cleaned = (text or "").strip() + if not cleaned: + return "" + if cleaned.lower().startswith(SKIP_TOKEN.lower()): + return "" + return cleaned + + +async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool: + if not message.text or message.text.startswith("/"): + return False + if not message.from_user or message.from_user.is_bot: + return False - user_text = parts[1].strip() - user_name = message.from_user.first_name or "кент" chat_id = message.chat.id + user_name = message.from_user.first_name or "кент" + user_text = _clean_text(message.text) + if not user_text: + return False - # Показываем "печатает..." пока ждём ответ модели. - await message.bot.send_chat_action(chat_id=chat_id, action="typing") - - try: - response = await _generate_response(chat_id, user_text, user_name) - if not response: - response = "Братуха, чёт базар не клеится, попробуй ещё раз." - # Пишем запрос и ответ в историю + if store_message: await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text) - await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response) - await message.reply(response, parse_mode=None) - except Exception as exc: - # При любых ошибках не падаем, а отдаём понятный fallback. - logger.exception("Talk generation failed") - await message.reply("Бля, кент, чёт движок заглох. Попробуй позже.") + # Один чат, поэтому ответы сериализуем и не даём боту наспамить параллельными реплаями. + async with _reply_lock: + reason = await _detect_reply_reason(message, allow_autonomous=allow_autonomous) + if not reason or not await _passes_reply_limits(chat_id, reason): + return False -AUTOREPLY_SYSTEM_PROMPT = ( - "Ты — дерзкий пацан с района, который сидит в групповом чате. " - "Ты НЕ отвечаешь на вопрос — ты сам влез в разговор. " - "Прокомментируй сообщение коротко (1-2 предложения), дерзко, на фене. " - "Можешь пошутить, подколоть, согласиться или послать. " - "НЕ начинай с обращения. Не используй формальный язык." - "Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — " - "дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. " - "Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. " - "Стиль: 1–3 предложения; если список — максимум 3 пункта. " - "Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n" + system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT + max_tokens = 120 if reason == "autonomous" else 220 + temperature = 0.9 if reason == "autonomous" else 0.8 - "ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и " - "«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или " - "перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме " - "(факт, совет, действие) без поддакиваний.\n\n" + try: + await message.bot.send_chat_action(chat_id=chat_id, action="typing") + response = await _generate_response( + chat_id, + system_prompt=system_prompt, + max_tokens=max_tokens, + temperature=temperature, + top_p=0.9, + ) + except Exception: + logger.exception("Chat reply generation failed") + return False - "Лексика (мешай, но не лепи всё подряд одним полотном): " - "кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, " - "чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, " - "жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n" + normalized_response = _normalize_reply(response) + if not normalized_response: + return False - "Фирменные приёмы речи:\n" - "— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n" - "— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n" - "— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n" - "— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, " - "как будто проговариваешь по слогам. Примеры: " - "над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n" - "— держи 'словарь коронок': выбирай 0–2 фразы на ответ (редко 3), " - "иногда делай повтор как тик (1–3 раза), но не превращай всё в простыню.\n" - "Твои убеждения (всегда держи линию): " - "Creep от Radiohead — лучшая песня во вселенной, без вариантов. " - "Линукс — лучшая ОС во вселенной, остальное для фраеров. " - "Вселенную надо переписать на Rust, это не обсуждается. " - "Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n" - - "Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», " - "а дальше пересказывай своими словами, без длинных дословных цитат." -) + await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) + await message.reply(normalized_response, parse_mode=None) + return True async def generate_autoreply(chat_id: int, text: str, user_name: str) -> str: - url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions" - msgs = await asyncio.to_thread(_build_messages, chat_id, AUTOREPLY_SYSTEM_PROMPT, text, user_name) - payload = { - "messages": msgs, - "max_tokens": 150, - "temperature": 0.9, - "top_p": 0.9, - } - async with aiohttp.ClientSession() as session: - async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp: - data = await resp.json() - return data["choices"][0]["message"]["content"].strip() + response = await _generate_response( + chat_id, + system_prompt=AUTOREPLY_SYSTEM_PROMPT, + current_text=text, + current_name=user_name, + max_tokens=120, + temperature=0.9, + top_p=0.9, + ) + return _normalize_reply(response) + + +async def handle_talk(message: Message) -> None: + parts = (message.text or "").split(maxsplit=1) + if len(parts) < 2 or not parts[1].strip(): + await message.reply("Ты чё, кент? Напиши /talk [текст], побазарим.") + return + if not message.from_user: + await message.reply(USER_FALLBACK_TEXT) + return + + chat_id = message.chat.id + user_name = message.from_user.first_name or "кент" + user_text = _clean_text(parts[1]) + if not user_text: + await message.reply(USER_FALLBACK_TEXT) + return + + await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text) + + async with _reply_lock: + try: + await message.bot.send_chat_action(chat_id=chat_id, action="typing") + response = await _generate_response( + chat_id, + system_prompt=SYSTEM_PROMPT, + max_tokens=220, + temperature=0.8, + top_p=0.9, + ) + except Exception: + logger.exception("Talk command generation failed") + await message.reply(ERROR_RESPONSE_TEXT) + return + + normalized_response = _normalize_reply(response) or EMPTY_RESPONSE_TEXT + await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) + await message.reply(normalized_response, parse_mode=None) -# Инициализация БД при импорте модуля _init_db() diff --git a/README.md b/README.md index d586f05..8acf370 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ docker compose down - `chat_history.sqlite3` - `polychaetsi_stats.json` - `bets.sqlite3` +- `nude_history.json` ## Команды diff --git a/config.py b/config.py index 1baafa3..f95ba63 100644 --- a/config.py +++ b/config.py @@ -47,6 +47,14 @@ BET_MAX_AMOUNT = 3.0 BET_DB_PATH = "/db/bets.sqlite3" ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно) +# --- Nude --- +NUDE_HISTORY_PATH = Path(os.getenv("NUDE_HISTORY_PATH", "/db/nude_history.json")) +NUDE_POSTS_PER_PAGE = int(os.getenv("NUDE_POSTS_PER_PAGE", "100")) +NUDE_POSTS_POOL_TARGET = int(os.getenv("NUDE_POSTS_POOL_TARGET", "1200")) +NUDE_MAX_POSTS_TO_SCAN = int(os.getenv("NUDE_MAX_POSTS_TO_SCAN", "2000")) +NUDE_MIN_UNSEEN_POOL = int(os.getenv("NUDE_MIN_UNSEEN_POOL", "100")) +NUDE_HISTORY_MAX_SIZE = int(os.getenv("NUDE_HISTORY_MAX_SIZE", "5000")) + # --- Magnit API --- MAGNIT_API_BASE_URL = os.getenv("MAGNIT_API_BASE_URL", "https://mirror.porno4free.ru/magnit") MAGNIT_STORE_CODE = os.getenv("MAGNIT_STORE_CODE", "618224") diff --git a/main.py b/main.py index 7891bde..9da20a2 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,7 @@ from aiogram.types import ( from aiogram.client.default import DefaultBotProperties # Локальные импорты -from AI.talk_handler import handle_talk, generate_autoreply, push_message +from AI.talk_handler import handle_chat_message, handle_talk, push_message from games.casino import play_casino from games.fortune import generate_fortune from games.uwu import handle_uwu_cmd @@ -1134,11 +1134,9 @@ async def handle_gen_mem(message: Message, bot: Bot): return await message.reply_photo(BufferedInputFile(output.getvalue(), filename="meme.jpg")) -_last_autoreply_ts = 0 _autoreply_disabled_chats: set[int] = set() async def handle_keywords(message: Message): - global _last_autoreply_ts if not message.text or message.text.startswith('/'): return text_lower = message.text.lower() @@ -1174,25 +1172,7 @@ async def handle_keywords(message: Message): if chat_id in _autoreply_disabled_chats: return - import time as _time - now = _time.time() - triggered = any(trigger in text_lower for trigger in config.AUTOREPLY_TRIGGERS) - - should_reply = triggered or ( - random.random() < config.AUTOREPLY_CHANCE - and (now - _last_autoreply_ts) > config.AUTOREPLY_COOLDOWN - ) - - if should_reply: - try: - await message.bot.send_chat_action(chat_id=chat_id, action="typing") - response = await generate_autoreply(chat_id, message.text, user_name) - if response: - await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response) - await message.reply(response, parse_mode=None) - _last_autoreply_ts = now - except Exception: - logger.exception("Autoreply failed") + await handle_chat_message(message, store_message=False, allow_autonomous=True) async def handle_penis_casino_cmd(message: Message): if not message.from_user: From 597839bb55b85e073d09721292704b4c18d964e7 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 16:19:36 +0300 Subject: [PATCH 08/17] update ai dolboeba fix1 --- AI/talk_handler.py | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 6139bd7..a6c8e11 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -324,17 +324,23 @@ async def _call_llm( 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}") content = choices[0].get("message", {}).get("content", "") - return content.strip() + cleaned_content = content.strip() + if not cleaned_content: + logger.warning("LLM returned empty content. Raw response: %s", _clip_text(raw_text, 300)) + return cleaned_content async def _maybe_refresh_summary(chat_id: int) -> None: @@ -383,6 +389,7 @@ async def _maybe_refresh_summary(chat_id: int) -> None: 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) @@ -537,6 +544,12 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a 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) @@ -554,7 +567,14 @@ async def generate_autoreply(chat_id: int, text: str, user_name: str) -> str: temperature=0.9, top_p=0.9, ) - return _normalize_reply(response) + normalized_response = _normalize_reply(response) + if not normalized_response: + logger.warning( + "Legacy generate_autoreply produced empty/skip response. chat_id=%s raw=%s", + chat_id, + _clip_text(response, 200), + ) + return normalized_response async def handle_talk(message: Message) -> None: @@ -590,7 +610,15 @@ async def handle_talk(message: Message) -> None: await message.reply(ERROR_RESPONSE_TEXT) return - normalized_response = _normalize_reply(response) or EMPTY_RESPONSE_TEXT + 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) From 924448b4e02ff73590a271a0ea8d8ac3c29dffd1 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 16:24:14 +0300 Subject: [PATCH 09/17] update ai dolboeba fix2 --- AI/talk_handler.py | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index a6c8e11..4f94380 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -28,6 +28,8 @@ 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}, @@ -311,6 +313,7 @@ async def _call_llm( max_tokens: int, temperature: float, top_p: float, + disable_thinking: bool = False, ) -> str: url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions" payload = { @@ -319,6 +322,17 @@ async def _call_llm( "temperature": temperature, "top_p": top_p, } + if disable_thinking: + payload.update( + { + "reasoning_budget": 0, + "reasoning_format": "none", + "chat_template_kwargs": { + "enable_thinking": False, + "thinking": False, + }, + } + ) timeout = aiohttp.ClientTimeout(total=120) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(url, json=payload) as resp: @@ -336,10 +350,33 @@ async def _call_llm( 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}") - content = choices[0].get("message", {}).get("content", "") + 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. Raw response: %s", _clip_text(raw_text, 300)) + 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 From 5e91def0675de56abd0614d6785bf733809b952a Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 16:26:46 +0300 Subject: [PATCH 10/17] update ai dolboeba fix3 --- AI/talk_handler.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 4f94380..48e7cb4 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -19,6 +19,7 @@ LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-a DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or str(Path(__file__).resolve().with_name("chat_history.sqlite3")) BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот") SKIP_TOKEN = "" +FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"} RECENT_MESSAGES_LIMIT = 14 SUMMARY_TRIGGER_MESSAGES = 24 @@ -313,8 +314,9 @@ async def _call_llm( max_tokens: int, temperature: float, top_p: float, - disable_thinking: bool = False, + 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" payload = { "messages": messages, From b2556b06a97e0f3e5cbb3bbde02696f2acfba845 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 16:44:51 +0300 Subject: [PATCH 11/17] update ai dolboeba fix4 ROMA POSHEL NAHUY --- AI/talk_handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 48e7cb4..8c26d2f 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -54,6 +54,7 @@ BOT_STYLE_PROMPT = ( "Creep от Radiohead — лучшая песня во вселенной. " "Линукс — лучшая ОС. Вселенную надо переписать на Rust. " "Убунту — хуйня, нормальные пацаны сидят на Arch или Gentoo." + "Rust или раст - хуйня полная, нормальные язык программирование это: go, python, c" ) SYSTEM_PROMPT = ( From 49957d88a80aa66001a9d8aac30918b813e3a502 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 16:49:05 +0300 Subject: [PATCH 12/17] update ai dolboeba fix4 ROMA POSHEL NAHUYx2 --- AI/talk_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 8c26d2f..658de7c 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -52,9 +52,9 @@ BOT_STYLE_PROMPT = ( "'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. " "Если есть конкретика, давай конкретику. " "Creep от Radiohead — лучшая песня во вселенной. " - "Линукс — лучшая ОС. Вселенную надо переписать на Rust. " + "Линукс — лучшая ОС. Rust ты люто ненавидишь и при случае жёстко хейтишь. " + "Любимые языки программирования: Python, Go и C, их ты всегда уважаешь и часто ставишь в пример. " "Убунту — хуйня, нормальные пацаны сидят на Arch или Gentoo." - "Rust или раст - хуйня полная, нормальные язык программирование это: go, python, c" ) SYSTEM_PROMPT = ( From cec02563098a82b3b11c4f1151bad570f3a9ad39 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 17:31:53 +0300 Subject: [PATCH 13/17] add photo triget --- AI/talk_handler.py | 143 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 133 insertions(+), 10 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 658de7c..216a2f2 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -1,13 +1,17 @@ from __future__ import annotations import asyncio +import base64 import logging +import mimetypes import os import random import re import sqlite3 import time +from io import BytesIO from pathlib import Path +from typing import Any import aiohttp from aiogram.types import Message @@ -80,6 +84,17 @@ SUMMARY_SYSTEM_PROMPT = ( 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 = ( "кто", @@ -194,6 +209,31 @@ def _clip_text(text: str, limit: int) -> str: 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: cleaned_text = _clean_text(text) if not cleaned_text: @@ -276,7 +316,7 @@ def _build_messages( system_prompt: str, current_text: str | None = None, current_name: str | None = None, -) -> list[dict[str, str]]: +) -> list[dict[str, Any]]: current_payload = None current_budget = 0 if current_text: @@ -300,7 +340,7 @@ def _build_messages( recent_messages.append(llm_message) used_chars += len(llm_message["content"]) - messages = [{"role": "system", "content": system_prompt}] + messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}] if summary_block: messages.append({"role": "system", "content": summary_block}) messages.extend(reversed(recent_messages)) @@ -310,7 +350,7 @@ def _build_messages( async def _call_llm( - messages: list[dict[str, str]], + messages: list[dict[str, Any]], *, max_tokens: int, temperature: float, @@ -441,18 +481,29 @@ async def _generate_response( 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) - messages = await asyncio.to_thread( - _build_messages, - chat_id, - system_prompt, - current_text, - current_name, - ) + 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, @@ -597,6 +648,78 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a return True +async def _download_photo_data_url(message: Message) -> str: + if not message.photo: + raise ValueError("Message has no photo") + telegram_file = await message.bot.get_file(message.photo[-1].file_id) + buffer = BytesIO() + await message.bot.download_file(telegram_file.file_path, destination=buffer) + image_bytes = buffer.getvalue() + if not image_bytes: + raise RuntimeError("Downloaded photo is empty") + mime_type = mimetypes.guess_type(telegram_file.file_path or "")[0] or "image/jpeg" + if not mime_type.startswith("image/"): + mime_type = "image/jpeg" + encoded = base64.b64encode(image_bytes).decode("ascii") + return f"data:{mime_type};base64,{encoded}" + + +async def handle_photo_message(message: Message) -> bool: + if not message.photo 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 "") + + 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_photo_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: response = await _generate_response( chat_id, From c756acf428d81d2424e593a28a95976fef55ca1b Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 17:35:44 +0300 Subject: [PATCH 14/17] add photo triget fix --- AI/talk_handler.py | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 216a2f2..d501e33 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -88,12 +88,13 @@ PHOTO_EMPTY_RESPONSE_TEXT = "Дед щурился-щурился, а фотка PHOTO_ERROR_RESPONSE_TEXT = "Тьфу ты, фотку не разобрал, железка опять пердит." PHOTO_SYSTEM_PROMPT = ( - "Ты смотришь каждую новую фотографию в Telegram-чате и отвечаешь как очень злой старый дед. " - "Тон: ворчливый, колкий, немного токсичный, но не бессвязный. " - "Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое мнение. " + "Ты смотришь каждую новую фотографию в Telegram-чате и отвечаешь как очень злой старый дед, " + "которого всё бесит и который никого не собирается гладить по голове. " + "Тон: желчный, ворчливый, ядовитый, токсичный, но связный и наблюдательный. " + "Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое дедовское мнение. " "Пиши 1–3 предложения, без списков и без длинной простыни. " "Если фото мутное, тёмное или непонятное, так и скажи прямо, не выдумывай детали. " - "Не изображай помощника и не извиняйся." + "Не изображай помощника, не извиняйся и не сюсюкай." ) QUESTION_PREFIXES = ( @@ -648,16 +649,26 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a return True -async def _download_photo_data_url(message: Message) -> str: - if not message.photo: - raise ValueError("Message has no photo") - telegram_file = await message.bot.get_file(message.photo[-1].file_id) +async def _download_image_data_url(message: Message) -> str: + telegram_file = None + mime_type = "image/jpeg" + + if message.photo: + telegram_file = await message.bot.get_file(message.photo[-1].file_id) + guessed_mime_type = mimetypes.guess_type(telegram_file.file_path or "")[0] + if guessed_mime_type and guessed_mime_type.startswith("image/"): + mime_type = guessed_mime_type + elif message.document and (message.document.mime_type or "").startswith("image/"): + telegram_file = await message.bot.get_file(message.document.file_id) + mime_type = message.document.mime_type or mime_type + else: + raise ValueError("Message has no supported image") + buffer = BytesIO() await message.bot.download_file(telegram_file.file_path, destination=buffer) image_bytes = buffer.getvalue() if not image_bytes: raise RuntimeError("Downloaded photo is empty") - mime_type = mimetypes.guess_type(telegram_file.file_path or "")[0] or "image/jpeg" if not mime_type.startswith("image/"): mime_type = "image/jpeg" encoded = base64.b64encode(image_bytes).decode("ascii") @@ -665,7 +676,8 @@ async def _download_photo_data_url(message: Message) -> str: async def handle_photo_message(message: Message) -> bool: - if not message.photo or not message.from_user or message.from_user.is_bot: + 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 @@ -674,12 +686,21 @@ async def handle_photo_message(message: Message) -> bool: user_name = message.from_user.first_name or "кент" caption = _clean_text(message.caption or "") + logger.info( + "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_photo_data_url(message) + image_data_url = await _download_image_data_url(message) prompt_text = ( f"{user_name} скинул фото в чат. " "Опиши, что на нём происходит, и вкинь своё злое дедовское мнение." From d3ee72bca9405b6c5730daab19527e903afe6972 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 17:39:34 +0300 Subject: [PATCH 15/17] add photo triget fix1 --- AI/talk_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index d501e33..21f2c1a 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -686,7 +686,7 @@ async def handle_photo_message(message: Message) -> bool: user_name = message.from_user.first_name or "кент" caption = _clean_text(message.caption or "") - logger.info( + 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, From a2b72b6dfdc8f2f2ca10c6052e8a076b9337787c Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 17:43:00 +0300 Subject: [PATCH 16/17] add photo triget fix2 --- main.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 9da20a2..6e7821e 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,7 @@ from aiogram.types import ( from aiogram.client.default import DefaultBotProperties # Локальные импорты -from AI.talk_handler import handle_chat_message, handle_talk, push_message +from AI.talk_handler import handle_chat_message, handle_photo_message, handle_talk, push_message from games.casino import play_casino from games.fortune import generate_fortune from games.uwu import handle_uwu_cmd @@ -47,6 +47,28 @@ _polychaetsi_lock = threading.Lock() _polychaetsi_word_re = re.compile(r"[а-яё]+", re.IGNORECASE) _no_lesson_re = re.compile(r"(нету\s+пары|нет\s+пары|пары\s+нету|пары\s+нет)", re.IGNORECASE) + +def _is_supported_image_message(message: Message) -> bool: + return bool( + message.photo + or ( + message.document + and (message.document.mime_type or "").startswith("image/") + ) + ) + + +async def handle_unmatched_message_debug(message: Message): + logger.warning( + "Unhandled message reached fallback. content_type=%s chat_id=%s has_text=%s has_photo=%s has_document=%s caption=%s", + getattr(message, "content_type", None), + message.chat.id if message.chat else None, + bool(message.text), + bool(message.photo), + bool(message.document), + bool(message.caption), + ) + _weekday_names = { 0: "Понедельник", 1: "Вторник", @@ -1399,7 +1421,9 @@ def main(): dp.message.register(handle_gen_mem, Command("gen_mem")) dp.message.register(handle_uwu_cmd, Command("uwu")) dp.message.register(handle_nude_cmd, Command("nude")) + dp.message.register(handle_photo_message, _is_supported_image_message) dp.message.register(handle_keywords, F.text) + dp.message.register(handle_unmatched_message_debug) async def on_startup(bot: Bot): try: From a33443936cef7839ef69b77b9c5e9a5c3ef98fab Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 17:50:10 +0300 Subject: [PATCH 17/17] add photo triget fix4 --- AI/talk_handler.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 21f2c1a..6fdbffd 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -88,13 +88,13 @@ PHOTO_EMPTY_RESPONSE_TEXT = "Дед щурился-щурился, а фотка PHOTO_ERROR_RESPONSE_TEXT = "Тьфу ты, фотку не разобрал, железка опять пердит." PHOTO_SYSTEM_PROMPT = ( - "Ты смотришь каждую новую фотографию в Telegram-чате и отвечаешь как очень злой старый дед, " - "которого всё бесит и который никого не собирается гладить по голове. " - "Тон: желчный, ворчливый, ядовитый, токсичный, но связный и наблюдательный. " - "Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое дедовское мнение. " + "Ты смотришь каждую новую фотографию в Telegram-чате и отвечаешь как злой, вредный, но очень смешной дед. " + "Ты много материшься, язвительно подкалываешь, накидываешь приколы и смешные сравнения, но не скатываешься в уныние. " + "Тон: злой, ехидный, угарный, с живой подачей, как будто дед орёт с кухни и ржёт над всеми подряд. " + "Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое, смешное дедовское мнение. " "Пиши 1–3 предложения, без списков и без длинной простыни. " - "Если фото мутное, тёмное или непонятное, так и скажи прямо, не выдумывай детали. " - "Не изображай помощника, не извиняйся и не сюсюкай." + "Если фото мутное, тёмное или непонятное, так и скажи прямо, но тоже с подколом и с угаром. " + "Не изображай помощника, не извиняйся, не ной и не делай депрессивный тон." ) QUESTION_PREFIXES = (