From c49b00ba15e7456f1053d0b8245ea4664703d24a Mon Sep 17 00:00:00 2001 From: Niken Date: Sun, 15 Mar 2026 20:36:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B3=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/gpt/handlers.py | 585 ++++++++++++++++++++++++++++------- addons/miniapps/handlers.py | 2 +- addons/mute/__init__.py | 9 + addons/mute/handlers.py | 76 +++++ addons/poll/handlers.py | 3 +- addons/x_days_to/handlers.py | 26 +- bot/core.py | 8 +- model_hash_map.json | 8 + requirements.txt | 163 +++++----- 9 files changed, 680 insertions(+), 200 deletions(-) create mode 100644 addons/mute/__init__.py create mode 100644 addons/mute/handlers.py create mode 100644 model_hash_map.json diff --git a/addons/gpt/handlers.py b/addons/gpt/handlers.py index e9dab8b..f39d824 100644 --- a/addons/gpt/handlers.py +++ b/addons/gpt/handlers.py @@ -2,21 +2,71 @@ import base64 import aiohttp import logging from aiogram import Dispatcher, Bot -from aiogram.types import Message +from aiogram.types import Message, InlineKeyboardButton, CallbackQuery +from aiogram.utils.keyboard import InlineKeyboardBuilder from utils.antispam import saving, save_message, admin_required from aiogram.filters import Command from models.state import BotState +import hashlib +import json +from typing import Dict, List, Tuple, Optional logger = logging.getLogger(__name__) +# Глобальные переменные на уровне модуля +current_model = "google/gemma-3-12b" +model_hash_map: Dict[str, str] = {} # Словарь для хранения соответствия хэшей и полных названий моделей + +# Список моделей, которые точно поддерживают изображения (можно расширять) +IMAGE_SUPPORTED_MODELS = [ + "llava", # Модели LLaVA + "vision", # Модели с поддержкой vision + "clip", # CLIP модели + "qwen2-vl", # Qwen с vision + "cogvlm", # CogVLM + "paligemma", # PaLI-Gemma + "gemma-2-2b-it", # Gemma 2 может поддерживать + "gemma-2-9b-it", + "gemini", # Gemini + "llava-hf", # LLaVA HuggingFace + "moondream", # Moondream +] + + +def get_model_hash(model_name: str) -> str: + """Создает короткий хэш для названия модели""" + return hashlib.md5(model_name.encode()).hexdigest()[:16] + + +def truncate_model_name(model_name: str, max_length: int = 40) -> str: + """Сокращает название модели для отображения""" + if len(model_name) <= max_length: + return model_name + return model_name[:max_length - 3] + "..." + + +def supports_images(model_name: str) -> bool: + """Проверяет, поддерживает ли модель изображения""" + model_lower = model_name.lower() + # Если модель содержит ключевые слова, связанные с поддержкой изображений + for keyword in IMAGE_SUPPORTED_MODELS: + if keyword.lower() in model_lower: + return True + return False + + def register_handlers(dp: Dispatcher, state: BotState, bot: Bot): chat_history = {} MAX_HISTORY = 20 # храним последние 20 сообщений (user+assistant) MODEL = "google/gemma-3-12b" - #MODEL = "google/gemma-3n-e4b" - URL = "http://192.168.31.197:1234/v1/chat/completions" - #URL = "http://192.168.31.95:1234/v1/chat/completions" + # MODEL = "google/gemma-3n-e4b" + URL_BASE = "10.180.139.124" + URL = f"http://{URL_BASE}:1234/v1/chat/completions" + # URL = "http://192.168.31.95:1234/v1/chat/completions" + MODELS_URL = f"http://{URL_BASE}:1234/v1/models" # URL для получения списка моделей + # Используем глобальные переменные + global current_model, model_hash_map # # Системный промт для всех чатов SYSTEM_PROMPT = { @@ -60,10 +110,171 @@ def register_handlers(dp: Dispatcher, state: BotState, bot: Bot): # ], # } - @dp.message(Command("gpt")) + @dp.message(Command("models")) + @admin_required(0) @saving - async def ask_gpt(message: Message): - chat_id = message.chat.id + async def list_models(message: Message): + """Получить список доступных моделей и выбрать одну""" + global model_hash_map, current_model + + try: + async with aiohttp.ClientSession() as session: + async with session.get(MODELS_URL) as resp: + if resp.status != 200: + error_text = await resp.text() + await message.reply( + f"❌ Ошибка при получении списка моделей: {resp.status} {error_text}" + ) + return + + data = await resp.json() + + # Формат ответа может отличаться в зависимости от API + models = [] + + # Формат OpenAI-compatible API + if isinstance(data, dict) and "data" in data: + models = [model["id"] for model in data["data"]] + + # Другой возможный формат + elif isinstance(data, list): + models = data + + # Если не распознали формат + else: + await message.reply(f"❌ Неизвестный формат ответа от API: {data}") + return + + if not models: + await message.reply("❌ Нет доступных моделей") + return + + # Создаем клавиатуру для выбора модели + builder = InlineKeyboardBuilder() + + for model in models: + # Создаем короткий хэш для callback_data + model_hash = get_model_hash(model) + model_hash_map[model_hash] = model # Сохраняем в глобальный словарь + + # Сокращаем для отображения + display_name = truncate_model_name(model) + + # Помечаем текущую модель и добавляем иконку для моделей с поддержкой изображений + prefix = "✅ " if model == current_model else "• " + icon = "🖼️ " if supports_images(model) else "" + + builder.row( + InlineKeyboardButton( + text=f"{prefix}{icon}{display_name}", + callback_data=f"model:{model_hash}" + ) + ) + + # Кнопки для управления + builder.row( + InlineKeyboardButton( + text="🔄 Обновить список", + callback_data="refresh_models" + ), + InlineKeyboardButton( + text="📋 Показать текущую", + callback_data="show_current" + ), + InlineKeyboardButton( + text="🖼️ Модели с картинками", + callback_data="show_image_models" + ) + ) + + # Сохраняем карту хэшей в файл для отладки (опционально) + try: + with open("model_hash_map.json", "w") as f: + json.dump(model_hash_map, f, indent=2, ensure_ascii=False) + except: + pass + + # Разделяем модели на поддерживающие изображения и обычные + image_models = [m for m in models if supports_images(m)] + text_only_models = [m for m in models if not supports_images(m)] + + models_list = "\n".join( + [f"{i + 1}. {model} {'🖼️' if supports_images(model) else ''}" for i, model in + enumerate(models[:10])]) + if len(models) > 10: + models_list += f"\n... и еще {len(models) - 10} моделей" + + await message.reply( + f"📋 Доступные модели ({len(models)} шт.):\n" + f"🖼️ Поддерживают изображения: {len(image_models)}\n" + f"📝 Только текст: {len(text_only_models)}\n" + f"Текущая модель: {current_model} {'🖼️' if supports_images(current_model) else '📝'}\n\n" + "Первые 10 моделей:\n" + models_list + "\n\n" + "Выберите модель из списка ниже (🖼️ - поддерживает изображения):", + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + + except Exception as e: + logger.error(f"Ошибка при получении списка моделей: {e}") + await message.reply(f"❌ Ошибка при получении списка моделей: {e}") + + @dp.message(Command("setmodel")) + @admin_required(0) + @saving + async def set_model(message: Message): + """Установить модель через команду""" + global current_model, model_hash_map + + if not message.text: + await message.reply("❌ Укажите название модели: /setmodel <название_модели>") + return + + args = message.text.split(maxsplit=1) + if len(args) < 2: + await message.reply("❌ Укажите название модели: /setmodel <название_модели>") + return + + new_model = args[1] + + # Проверяем, есть ли модель в текущем кэше (опционально) + if model_hash_map: + model_hashes = list(model_hash_map.values()) + if new_model not in model_hashes: + await message.reply( + f"⚠️ Модель {new_model} не найдена в последнем списке.\n" + f"Используйте /models для просмотра доступных моделей.\n" + f"Продолжаем смену модели...", + parse_mode="HTML" + ) + + old_model = current_model + current_model = new_model + + # Сообщаем о поддержке изображений + image_support = "🖼️ поддерживает изображения" if supports_images(new_model) else "📝 только текст" + + await message.reply( + f"✅ Модель успешно изменена! ({image_support})\n" + f"📊 Старая модель: {old_model}\n" + f"📈 Новая модель: {current_model}", + parse_mode="HTML" + ) + + @dp.message(Command("currentmodel")) + @saving + async def show_current_model(message: Message): + """Показать текущую модель""" + global current_model + image_support = "🖼️ Поддерживает изображения" if supports_images(current_model) else "📝 Только текст" + await message.reply( + f"🤖 Текущая модель: {current_model}\n" + f"{image_support}", + parse_mode="HTML" + ) + + async def prepare_gpt_request(chat_id: int, message: Message) -> Tuple[Optional[List[dict]], Optional[str]]: + """Подготавливает запрос к GPT, обрабатывая текст и изображения""" if chat_id not in chat_history: chat_history[chat_id] = [SYSTEM_PROMPT] @@ -78,26 +289,42 @@ def register_handlers(dp: Dispatcher, state: BotState, bot: Bot): if message.caption: user_prompt = message.caption + # Добавляем текстовый промпт, если есть if user_prompt: content_blocks.append({"type": "text", "text": user_prompt}) + elif not message.photo: + # Если нет ни текста, ни фото + return None, "❗ Укажи текст или прикрепи фото" - # Фото → base64 → image_url - if message.photo: - photo = message.photo[-1] - file = await bot.get_file(photo.file_id) - file_bytes = await bot.download_file(file.file_path) - image_b64 = base64.b64encode(file_bytes.read()).decode("utf-8") - content_blocks.append( - { - "type": "image_url", - "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}, - } - ) + # Обрабатываем фото, если модель поддерживает изображения + if message.photo and supports_images(current_model): + try: + photo = message.photo[-1] + file = await bot.get_file(photo.file_id) + file_bytes = await bot.download_file(file.file_path) + image_b64 = base64.b64encode(file_bytes.read()).decode("utf-8") + content_blocks.append( + { + "type": "image_url", + "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}, + } + ) + except Exception as e: + logger.error(f"Ошибка при обработке изображения: {e}") + # Если не удалось обработать изображение, продолжаем без него + if not user_prompt: + return None, "❌ Ошибка при обработке изображения" + elif message.photo and not supports_images(current_model): + # Если модель не поддерживает изображения, отправляем текстовое описание + if not user_prompt: + # Если нет текста, просим переформулировать + return None, f"⚠️ Текущая модель {current_model} не поддерживает изображения.\nОтправьте текстовое описание или смените модель на поддерживающую изображения (🖼️)." + # Если есть текст, просто игнорируем изображение и отправляем текст + logger.info(f"Модель {current_model} не поддерживает изображения, отправляем только текст") + # Если после всех проверок content_blocks пустой if not content_blocks: - await message.reply("❗ Укажи текст или прикрепи фото") - return - + return None, "❗ Укажи текст или прикрепи фото" # Добавляем новое сообщение в историю chat_history[chat_id].append({"role": "user", "content": content_blocks}) @@ -106,27 +333,86 @@ def register_handlers(dp: Dispatcher, state: BotState, bot: Bot): if len(chat_history[chat_id]) > MAX_HISTORY: chat_history[chat_id] = chat_history[chat_id][-MAX_HISTORY:] + return chat_history[chat_id], None + + @dp.message(Command("gpt")) + @saving + async def ask_gpt(message: Message): + global current_model + chat_id = message.chat.id + + # Подготавливаем запрос + prepared_history, error_message = await prepare_gpt_request(chat_id, message) + if error_message: + await message.reply(error_message, parse_mode="HTML") + return + payload = { - "model": MODEL, - "messages": chat_history[chat_id], + "model": current_model, + "messages": prepared_history, "temperature": 0.7, "max_tokens": 4096, "stream": False, "ttl": 300, } + # Флаг, указывающий на попытку отправки изображения + has_image = bool(message.photo) + image_attempt_failed = False + try: async with aiohttp.ClientSession() as session: async with session.post(URL, json=payload) as resp: if resp.status != 200: error_text = await resp.text() - await message.reply( - f"❌ Ошибка LM Studio: {resp.status} {error_text}" - ) - return - data = await resp.json() - reply_text = data["choices"][0]["message"]["content"] + # Проверяем, если ошибка из-за изображения + if has_image and "does not support images" in error_text: + image_attempt_failed = True + logger.warning( + f"Модель {current_model} не поддерживает изображения, пробуем без изображения") + + # Удаляем последнее сообщение из истории (с изображением) + chat_history[chat_id].pop() + + # Пробуем отправить только текст + if message.caption: + # Используем caption как промпт + content_blocks = [{"type": "text", "text": message.caption}] + elif message.text: + parts = message.text.split(maxsplit=1) + if len(parts) > 1: + content_blocks = [{"type": "text", "text": parts[1]}] + else: + content_blocks = [{"type": "text", "text": "Опиши изображение"}] + else: + content_blocks = [{"type": "text", "text": "Опиши изображение"}] + + # Добавляем текстовый запрос + chat_history[chat_id].append({"role": "user", "content": content_blocks}) + + # Обновляем payload + payload["messages"] = chat_history[chat_id] + + # Повторяем запрос без изображения + async with session.post(URL, json=payload) as retry_resp: + if retry_resp.status != 200: + error_text = await retry_resp.text() + await message.reply( + f"❌ Ошибка LM Studio: {retry_resp.status} {error_text}" + ) + return + + data = await retry_resp.json() + reply_text = data["choices"][0]["message"]["content"] + else: + await message.reply( + f"❌ Ошибка LM Studio: {resp.status} {error_text}" + ) + return + else: + data = await resp.json() + reply_text = data["choices"][0]["message"]["content"] # Сохраняем ответ ассистента в историю chat_history[chat_id].append( @@ -140,14 +426,21 @@ def register_handlers(dp: Dispatcher, state: BotState, bot: Bot): if len(chat_history[chat_id]) > MAX_HISTORY: chat_history[chat_id] = chat_history[chat_id][-MAX_HISTORY:] + # Добавляем заметку о попытке отправки изображения + image_note = "" + if image_attempt_failed: + image_note = f"\n\n⚠️ Модель не поддерживает изображения, отправлен только текстовый запрос." + # Делим сообщение на части по 4000 символов MAX_LEN = 4000 for i in range(0, len(reply_text), MAX_LEN): chunk = reply_text[i:i + MAX_LEN] - msg = await message.reply(f"🤖 Ответ:\n{chunk}") + if i == 0: + msg = await message.reply(f"🤖 Ответ (модель: {current_model}){image_note}:\n{chunk}") + else: + msg = await message.reply(chunk) save_message(msg.chat.id, msg.message_id) - except Exception as e: logger.error(f"Ошибка при запросе к LM Studio: {e}") await message.reply(f"❌ Ошибка при запросе к LM Studio: {e}") @@ -155,59 +448,27 @@ def register_handlers(dp: Dispatcher, state: BotState, bot: Bot): @dp.message(Command("agpt")) @admin_required(0) @saving - async def ask_gpt(message: Message): + async def ask_gpt_admin(message: Message): + global current_model chat_id = message.chat.id - if chat_id not in chat_history: - chat_history[chat_id] = [SYSTEM_PROMPT] - content_blocks = [] - user_prompt = None - - # Текст после команды или caption - if message.text: - parts = message.text.split(maxsplit=1) - if len(parts) > 1: - user_prompt = parts[1] - if message.caption: - user_prompt = message.caption - - if user_prompt: - content_blocks.append({"type": "text", "text": user_prompt}) - - # Фото → base64 → image_url - if message.photo: - photo = message.photo[-1] - file = await bot.get_file(photo.file_id) - file_bytes = await bot.download_file(file.file_path) - image_b64 = base64.b64encode(file_bytes.read()).decode("utf-8") - content_blocks.append( - { - "type": "image_url", - "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}, - } - ) - - if not content_blocks: - await message.reply("❗ Укажи текст или прикрепи фото") + # Подготавливаем запрос + prepared_history, error_message = await prepare_gpt_request(chat_id, message) + if error_message: + await message.reply(error_message, parse_mode="HTML") return - - # Добавляем новое сообщение в историю - chat_history[chat_id].append({"role": "user", "content": content_blocks}) - - # Ограничиваем историю (оставляем последние MAX_HISTORY сообщений) - if len(chat_history[chat_id]) > MAX_HISTORY: - chat_history[chat_id] = chat_history[chat_id][-MAX_HISTORY:] - payload = { - "model": MODEL, - "messages": chat_history[chat_id], + "model": current_model, + "messages": prepared_history, "temperature": 0.7, "max_tokens": 4096, "stream": False, "ttl": 300, } + target_chat_id = -1003038389942 + try: async with aiohttp.ClientSession() as session: async with session.post(URL, json=payload) as resp: @@ -237,10 +498,10 @@ def register_handlers(dp: Dispatcher, state: BotState, bot: Bot): MAX_LEN = 4000 for i in range(0, len(reply_text), MAX_LEN): chunk = reply_text[i:i + MAX_LEN] - msg = await bot.send_message(chat_id=-1003038389942, text=f"{chunk}") + msg = await bot.send_message(chat_id=target_chat_id, + text=f"🤖 Ответ (модель: {current_model}):\n{chunk}") save_message(msg.chat.id, msg.message_id) - except Exception as e: logger.error(f"Ошибка при запросе к LM Studio: {e}") await message.reply(f"❌ Ошибка при запросе к LM Studio: {e}") @@ -248,13 +509,12 @@ def register_handlers(dp: Dispatcher, state: BotState, bot: Bot): @dp.message(Command("igpt")) @admin_required(0) @saving - async def ask_gpt(message: Message): + async def ask_gpt_interactive(message: Message): + global current_model raw_text = message.text or message.caption - if not raw_text and not ( - message.photo or message.document or message.audio or message.video - ): + if not raw_text and not (message.photo): await message.reply( - "❌ Укажи ID чата и текст или прикрепи файл/медиа: /igpt <сообщение>" + "❌ Укажи ID чата и текст или прикрепи фото: /igpt <сообщение>" ) return @@ -264,47 +524,60 @@ def register_handlers(dp: Dispatcher, state: BotState, bot: Bot): return try: - chat_id = int(args[1]) # первый аргумент — ID чата + target_chat_id = int(args[1]) # первый аргумент — ID чата except ValueError: await message.reply("❌ Неверный формат chat_id") return user_prompt = args[2] if len(args) > 2 else "" - if chat_id not in chat_history: - chat_history[chat_id] = [SYSTEM_PROMPT] + + # Используем локальный chat_id для обработки запроса + local_chat_id = message.chat.id + if local_chat_id not in chat_history: + chat_history[local_chat_id] = [SYSTEM_PROMPT] content_blocks = [] if user_prompt: content_blocks.append({"type": "text", "text": user_prompt}) - # Фото → base64 → image_url - if message.photo: - photo = message.photo[-1] - file = await bot.get_file(photo.file_id) - file_bytes = await bot.download_file(file.file_path) - image_b64 = base64.b64encode(file_bytes.read()).decode("utf-8") - content_blocks.append( - { - "type": "image_url", - "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}, - } - ) + # Фото → base64 → image_url (только если модель поддерживает) + if message.photo and supports_images(current_model): + try: + photo = message.photo[-1] + file = await bot.get_file(photo.file_id) + file_bytes = await bot.download_file(file.file_path) + image_b64 = base64.b64encode(file_bytes.read()).decode("utf-8") + content_blocks.append( + { + "type": "image_url", + "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}, + } + ) + except Exception as e: + logger.error(f"Ошибка при обработке изображения: {e}") + if not user_prompt: + await message.reply("❌ Ошибка при обработке изображения") + return + elif message.photo and not supports_images(current_model): + await message.reply(f"⚠️ Текущая модель {current_model} не поддерживает изображения.", + parse_mode="HTML") + if not user_prompt: + return if not content_blocks: await message.reply("❗ Укажи текст или прикрепи фото") return - # Добавляем новое сообщение в историю - chat_history[chat_id].append({"role": "user", "content": content_blocks}) + chat_history[local_chat_id].append({"role": "user", "content": content_blocks}) - # Ограничиваем историю (оставляем последние MAX_HISTORY сообщений) - if len(chat_history[chat_id]) > MAX_HISTORY: - chat_history[chat_id] = chat_history[chat_id][-MAX_HISTORY:] + # Ограничиваем историю + if len(chat_history[local_chat_id]) > MAX_HISTORY: + chat_history[local_chat_id] = chat_history[local_chat_id][-MAX_HISTORY:] payload = { - "model": MODEL, - "messages": chat_history[chat_id], + "model": current_model, + "messages": chat_history[local_chat_id], "temperature": 0.7, "max_tokens": 4096, "stream": False, @@ -325,29 +598,121 @@ def register_handlers(dp: Dispatcher, state: BotState, bot: Bot): reply_text = data["choices"][0]["message"]["content"] # Сохраняем ответ ассистента в историю - chat_history[chat_id].append( + chat_history[local_chat_id].append( { "role": "assistant", "content": [{"type": "text", "text": reply_text}], } ) - # Ограничиваем снова (чтобы не разрасталось) - if len(chat_history[chat_id]) > MAX_HISTORY: - chat_history[chat_id] = chat_history[chat_id][-MAX_HISTORY:] + # Ограничиваем снова + if len(chat_history[local_chat_id]) > MAX_HISTORY: + chat_history[local_chat_id] = chat_history[local_chat_id][-MAX_HISTORY:] # Делим сообщение на части по 4000 символов MAX_LEN = 4000 for i in range(0, len(reply_text), MAX_LEN): chunk = reply_text[i:i + MAX_LEN] - msg = await bot.send_message(chat_id=chat_id, text=chunk) + msg = await bot.send_message(chat_id=target_chat_id, + text=f"🤖 Ответ (модель: {current_model}):\n{chunk}") save_message(msg.chat.id, msg.message_id) - except Exception as e: logger.error(f"Ошибка при запросе к LM Studio: {e}") await message.reply(f"❌ Ошибка при запросе к LM Studio: {e}") @dp.message(Command("clear")) async def clear(message: Message): - chat_history.pop(message.chat.id, None) \ No newline at end of file + chat_history.pop(message.chat.id, None) + await message.reply("✅ История диалога очищена") + + # Создаем простые фильтры для callback + async def model_callback_filter(callback_query: CallbackQuery) -> bool: + return callback_query.data.startswith("model:") + + async def refresh_models_filter(callback_query: CallbackQuery) -> bool: + return callback_query.data == "refresh_models" + + async def show_current_filter(callback_query: CallbackQuery) -> bool: + return callback_query.data == "show_current" + + async def show_image_models_filter(callback_query: CallbackQuery) -> bool: + return callback_query.data == "show_image_models" + + @dp.callback_query(model_callback_filter) + async def select_model_callback(callback_query: CallbackQuery): + """Обработка выбора модели из инлайн-клавиатуры""" + global current_model, model_hash_map + + model_hash = callback_query.data.split(":", 1)[1] + + # Пытаемся загрузить из файла, если в памяти нет + if not model_hash_map: + try: + with open("model_hash_map.json", "r") as f: + model_hash_map = json.load(f) + except Exception as e: + logger.error(f"Ошибка загрузки model_hash_map из файла: {e}") + + # Получаем полное название модели из карты + if model_hash not in model_hash_map: + await callback_query.answer("❌ Ошибка: модель не найдена в кэше. Используйте /models для обновления списка", + show_alert=True) + return + + model_id = model_hash_map[model_hash] + old_model = current_model + current_model = model_id + + # Сообщаем о поддержке изображений + image_support = "🖼️ поддерживает изображения" if supports_images(model_id) else "📝 только текст" + + # Обновляем сообщение + try: + await callback_query.message.edit_text( + f"✅ Модель успешно изменена! ({image_support})\n\n" + f"📊 Старая модель:\n{old_model}\n\n" + f"📈 Новая модель:\n{current_model}\n\n" + f"Для просмотра всех моделей используйте /models", + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Ошибка при редактировании сообщения: {e}") + + # Отправляем подтверждение + await callback_query.answer(f"✅ Модель изменена на:\n{truncate_model_name(model_id, 30)}") + + @dp.callback_query(refresh_models_filter) + async def refresh_models_callback(callback_query: CallbackQuery): + """Обновить список моделей""" + await list_models(callback_query.message) + await callback_query.answer("🔄 Список моделей обновлен") + + @dp.callback_query(show_current_filter) + async def show_current_callback(callback_query: CallbackQuery): + """Показать текущую модель""" + global current_model + image_support = "🖼️ Поддерживает изображения" if supports_images(current_model) else "📝 Только текст" + await callback_query.answer(f"Текущая модель: {current_model}\n{image_support}", show_alert=True) + + @dp.callback_query(show_image_models_filter) + async def show_image_models_callback(callback_query: CallbackQuery): + """Показать модели с поддержкой изображений""" + global model_hash_map + + if not model_hash_map: + await callback_query.answer("❌ Список моделей пуст. Используйте /models", show_alert=True) + return + + image_models = [model for model in model_hash_map.values() if supports_images(model)] + + if not image_models: + await callback_query.answer("🖼️ Нет моделей с поддержкой изображений", show_alert=True) + return + + models_text = "\n".join([f"• {model}" for model in image_models[:10]]) + if len(image_models) > 10: + models_text += f"\n... и еще {len(image_models) - 10} моделей" + + await callback_query.answer(f"🖼️ Модели с поддержкой изображений ({len(image_models)} шт.):\n{models_text}", + show_alert=True) \ No newline at end of file diff --git a/addons/miniapps/handlers.py b/addons/miniapps/handlers.py index e6f6a5e..aeba302 100644 --- a/addons/miniapps/handlers.py +++ b/addons/miniapps/handlers.py @@ -20,7 +20,7 @@ def register_handlers(dp: Dispatcher, state: BotState, bot: Bot): async def send_welcome(message: Message): # Создаём инлайн-кнопку для открытия Web App keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="Открыть мини-приложение", web_app=WebAppInfo(url=f"https://college.by/accounts/raspis/{datetime.now().month}/{get_day()}-PODNAM.htm"))] + [InlineKeyboardButton(text="Открыть мини-приложение", web_app=WebAppInfo(url="https://overfit-percussively-nicolas.ngrok-free.dev"))] ]) await message.answer( diff --git a/addons/mute/__init__.py b/addons/mute/__init__.py new file mode 100644 index 0000000..8a90404 --- /dev/null +++ b/addons/mute/__init__.py @@ -0,0 +1,9 @@ +def register(dp, state, bot): + from . import handlers + + handlers.register_handlers(dp, state, bot) + + +def unregister(dp): + # Здесь можно удалить хендлеры, если нужно + dp.message_handlers.handlers.clear() \ No newline at end of file diff --git a/addons/mute/handlers.py b/addons/mute/handlers.py new file mode 100644 index 0000000..94e9298 --- /dev/null +++ b/addons/mute/handlers.py @@ -0,0 +1,76 @@ +from aiogram import Dispatcher, Bot +from aiogram.types import Message, ChatPermissions +from aiogram.filters import Command +from logging import getLogger +from datetime import timedelta +import asyncio + +from utils.antispam import admin_required +from models.state import BotState + + +logger = getLogger(__name__) + +# Максимальное время мьюта — 4 минуты (240 секунд) +MAX_MUTE_SECONDS = 4 * 60 # 240 + + +def register_handlers(dp: Dispatcher, state: BotState, bot: Bot): + @dp.message(Command("mute")) + @admin_required(0) + async def mute_user(message: Message): + logger.info(f"Команда /mute от пользователя {message.from_user.id} в чате {message.chat.id}") + + if not message.reply_to_message: + await message.reply("Эта команда должна использоваться в ответ на сообщение.") + return + + command_text = message.text or "" + if command_text.lower().startswith("/mute@"): + command_prefix = command_text.split()[0] + else: + command_prefix = "/mute" + + args_part = command_text[len(command_prefix):].strip() + args = args_part.split() if args_part else [] + + # Парсим время (по умолчанию 60 секунд) + mute_time = int(args[0]) if args and args[0].isdigit() else 60 + reason = " ".join(args[1:]) if len(args) > 1 else "Без причины" + + # Ограничиваем максимум 4 минутами + if mute_time > MAX_MUTE_SECONDS: + old_time = mute_time + mute_time = MAX_MUTE_SECONDS + reason += f" (время ограничено 4 минутами, было указано {old_time} сек)" + + user_id = message.reply_to_message.from_user.id + chat_id = message.chat.id + + try: + await bot.restrict_chat_member( + chat_id=chat_id, + user_id=user_id, + permissions=ChatPermissions(can_send_messages=False), + until_date=message.date + timedelta(seconds=mute_time) + ) + + await message.delete() + + notification = await message.reply_to_message.reply( + f"⛔ Вас замьютили на {mute_time} секунд.\nПричина: {reason}" + ) + + asyncio.create_task(delete_after_delay(notification, delay=5)) + + except Exception as e: + logger.error(f"Ошибка при муте пользователя {user_id}: {e}") + await message.reply("Не удалось замьютить пользователя. Возможно, у меня недостаточно прав или пользователь — админ.") + + +async def delete_after_delay(message: Message, delay: int = 5): + await asyncio.sleep(delay) + try: + await message.delete() + except Exception as e: + logger.debug(f"Не удалось удалить уведомление о мьюте: {e}") \ No newline at end of file diff --git a/addons/poll/handlers.py b/addons/poll/handlers.py index 320c9c7..28029bd 100644 --- a/addons/poll/handlers.py +++ b/addons/poll/handlers.py @@ -1,11 +1,10 @@ from config import Config from utils.antispam import admin_required from aiogram import Dispatcher, Bot -from aiogram.types import Message, FSInputFile +from aiogram.types import Message from models.state import BotState from aiogram.filters import Command from logging import getLogger -from aiogram.types import PollAnswer from storage.message_storage import save_message logger = getLogger(__name__) diff --git a/addons/x_days_to/handlers.py b/addons/x_days_to/handlers.py index 39eb011..058d73f 100644 --- a/addons/x_days_to/handlers.py +++ b/addons/x_days_to/handlers.py @@ -58,9 +58,20 @@ def register_handlers(dp: Dispatcher, state: BotState, bot: Bot) -> int: logger.debug(f"До лета осталось {delta} дней") return delta + async def days_to_session() -> int: + """Считает дни до 1 июня текущего года (или следующего, если уже лето прошло).""" + now = datetime.now() + summer = datetime(2026, 7, 6) + if now >= summer: + logger.warning("days_to_session") + delta = (summer - now).days + logger.debug(f"До Сессии осталось {delta} дней") + return delta + async def send_days_to_new_years(user_id: int): days_ny = await days_to_new_years() days_summer = await days_to_summer() + days_session = await days_to_session() last_days = await get_last_days(user_id) if last_days == days_ny: @@ -69,10 +80,17 @@ def register_handlers(dp: Dispatcher, state: BotState, bot: Bot) -> int: await save_days_to_db(user_id, days_ny) - message_text = ( - f"🌞 До лета осталось {days_summer} дней!\n" - f"🎄 До Нового Года осталось {days_ny} дней!" - ) + events = [ + ("🌞 До лета", days_summer), + ("📚 До конца сессии", days_session), + ("🎄 До Нового года", days_ny), + ] + + # сортировка по числу дней (от меньшего к большему) + events_sorted = sorted(events, key=lambda x: x[1]) + + message_text = "\n".join([f"{emoji} осталось {days} дней!" for emoji, days in events_sorted]) + for chat_id in Config.CHAT_IDS: try: diff --git a/bot/core.py b/bot/core.py index 839632f..0e6f059 100644 --- a/bot/core.py +++ b/bot/core.py @@ -17,7 +17,7 @@ class TelegramBot: # Регистрируем обработчики из разных модулей admin.register_handlers(self.dp, self.state, self.bot) - schedule.register_handlers(self.dp, self.state) + # schedule.register_handlers(self.dp, self.state) # media.register_handlers(self.dp, self.state, self.bot) # common.register_handlers(self.dp, self.state, self.bot) @@ -26,10 +26,10 @@ class TelegramBot: self.addons.load("id") self.addons.load("send_message") self.addons.load("poll") - # self.addons.load("hello") - self.addons.load("draw") + self.addons.load("hello") + # self.addons.load("draw") self.addons.load("gpt") - # self.addons.load("rule34") + self.addons.load("rule34") # self.addons.load("todo") self.addons.load("miniapps") self.addons.load("x_days_to") diff --git a/model_hash_map.json b/model_hash_map.json new file mode 100644 index 0000000..f1fc4b6 --- /dev/null +++ b/model_hash_map.json @@ -0,0 +1,8 @@ +{ + "ca7463d0df0fc143": "captainerisnebula-12b-chimera-v1.1-iq-imatrix", + "b6886fc896f68593": "liquid/lfm2.5-1.2b", + "43efb8b5d51c38d7": "tiefighter-holodeck-holomax-mythomax-f1-v1-compos-20b", + "4f9b128b9b567451": "text-embedding-nomic-embed-text-v1.5", + "1ce7277325bb3050": "google/gemma-3n-e4b", + "390b8888e1038e17": "google/gemma-3-12b" +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 17a5357..5427acd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,79 +1,84 @@ -aenum==3.1.16 -aiofiles==24.1.0 -aiogram==3.22.0 -aiohappyeyeballs==2.6.1 -aiohttp==3.12.15 -aiosignal==1.4.0 -annotated-types==0.7.0 -anyio==4.11.0 -attrs==25.3.0 -beautifulsoup4==4.14.2 -bs4==0.0.2 -certifi==2025.8.3 -cffi==2.0.0 -charset-normalizer==3.4.3 -click==8.3.0 -cryptography==46.0.2 -dataclasses-json==0.6.7 -deepgram-sdk==3.11.0 -deprecation==2.1.0 -distro==1.9.0 -dotenv==0.9.9 -dropbox==12.0.2 -filelock==3.20.0 -frozenlist==1.7.0 -fsspec==2025.9.0 -greenlet==3.2.4 -h11==0.16.0 -hf-xet==1.1.10 -httpcore==1.0.9 -httpx==0.28.1 -huggingface-hub==0.35.3 -idna==3.10 -Jinja2==3.1.6 -jiter==0.11.1 -joblib==1.5.2 -magic-filter==1.0.12 -MarkupSafe==3.0.3 -marshmallow==3.26.1 -mpmath==1.3.0 -msal==1.34.0 -multidict==6.6.4 -mutagen==1.47.0 -mypy_extensions==1.1.0 -networkx==3.5 -numpy==2.3.4 -openai==2.5.0 -packaging==25.0 -playwright==1.55.0 -ply==3.11 -propcache==0.3.2 -pycparser==2.23 -pydantic==2.11.10 -pydantic_core==2.33.2 -pyee==13.0.0 -PyJWT==2.10.1 -python-dotenv==1.1.1 -PyYAML==6.0.3 -regex==2025.9.18 -requests==2.32.5 -ruff==0.14.0 -sacremoses==0.1.1 -safetensors==0.6.2 -sentencepiece==0.2.1 -setuptools==80.9.0 -six==1.17.0 -sniffio==1.3.1 -soupsieve==2.8 -stone==3.3.1 -sympy==1.14.0 -tokenizers==0.22.1 -torch==2.9.0 -tqdm==4.67.1 -transformers==4.57.1 -typing-inspect==0.9.0 -typing-inspection==0.4.2 -typing_extensions==4.15.0 -urllib3==2.5.0 -websockets==15.0.1 -yarl==1.20.1 + aenum==3.1.16 + aiofiles==24.1.0 + aiogram==3.22.0 + aiohappyeyeballs==2.6.1 + aiohttp==3.12.15 + aiosignal==1.4.0 + aiosqlite==0.21.0 + annotated-types==0.7.0 + anyio==4.11.0 + attrs==25.3.0 + beautifulsoup4==4.14.2 + bs4==0.0.2 + certifi==2025.8.3 + cffi==2.0.0 + charset-normalizer==3.4.3 + click==8.3.0 + cryptography==46.0.2 + dataclasses-json==0.6.7 + deepgram-sdk==3.11.0 + deprecation==2.1.0 + distro==1.9.0 + dotenv==0.9.9 + dropbox==12.0.2 + filelock==3.20.0 + frozenlist==1.7.0 + fsspec==2025.9.0 + greenlet==3.2.4 + h11==0.16.0 + hf-xet==1.1.10 + httpcore==1.0.9 + httpx==0.28.1 + huggingface-hub==0.35.3 + idna==3.10 + Jinja2==3.1.6 + jiter==0.11.1 + joblib==1.5.2 + lxml==6.0.2 + magic-filter==1.0.12 + MarkupSafe==3.0.3 + marshmallow==3.26.1 + mpmath==1.3.0 + msal==1.34.0 + multidict==6.6.4 + mutagen==1.47.0 + mypy_extensions==1.1.0 + networkx==3.5 + numpy==2.3.4 + ollama==0.6.0 + openai==2.5.0 + packaging==25.0 + pillow==12.0.0 + playwright==1.55.0 + ply==3.11 + propcache==0.3.2 + pycparser==2.23 + pydantic==2.11.10 + pydantic_core==2.33.2 + pyee==13.0.0 + PyJWT==2.10.1 + python-dotenv==1.1.1 + PyYAML==6.0.3 + regex==2025.9.18 + requests==2.32.5 + ruff==0.14.6 + sacremoses==0.1.1 + safetensors==0.6.2 + sentencepiece==0.2.1 + setuptools==80.9.0 + six==1.17.0 + sniffio==1.3.1 + soupsieve==2.8 + stone==3.3.1 + sympy==1.14.0 + tokenizers==0.22.1 + torch==2.9.0 + tqdm==4.67.1 + transformers==4.57.1 + typing-inspect==0.9.0 + typing-inspection==0.4.2 + typing_extensions==4.15.0 + urllib3==2.5.0 + websockets==15.0.1 + yarl==1.20.1 + yt-dlp==2025.10.22