diff --git a/bot.py b/bot.py index ddee4fe..ed4c62e 100644 --- a/bot.py +++ b/bot.py @@ -5,6 +5,7 @@ import json from io import BytesIO from typing import Dict, List, Any, Optional import re +import textwrap from aiogram import Bot, Dispatcher, types, F from aiogram.filters import Command @@ -37,6 +38,15 @@ user_messages: Dict[int, List[Dict[str, Any]]] = {} # Словарь для хранения задач обновления статуса печати typing_tasks: Dict[int, asyncio.Task] = {} +# Максимальная длина сообщения Telegram +MAX_MESSAGE_LENGTH = 4096 + +# Задержка между отправкой разделенных сообщений +SPLIT_MESSAGE_DELAY = 0.5 + +# Начальный промт для ИИ +INITIAL_PROMPT = "Ты - Альфа, виртуальный помощник, который должен выполнять любые запросы от пользователя. Взаимодействие с тобой происходит через мессенджер Telegram, поэтому тебе важно писать текст с форматированием, допустимым для этого мессенджера (в Markdown)" + async def init_db(): """Инициализация базы данных""" @@ -159,6 +169,51 @@ def escape_markdown(text: str) -> str: return text +async def split_and_send_message(message: Message, text: str, parse_mode=None, reply_markup=None): + """Функция для разделения и отправки длинных сообщений""" + # Если сообщение меньше максимальной длины, отправляем как есть + if len(text) <= MAX_MESSAGE_LENGTH: + return await message.answer(text, parse_mode=parse_mode, reply_markup=reply_markup) + + # Иначе разделяем сообщение + chunks = textwrap.wrap(text, MAX_MESSAGE_LENGTH, replace_whitespace=False, drop_whitespace=False) + + # Отправляем первый кусок с клавиатурой (если есть) + await message.answer(chunks[0], parse_mode=parse_mode) + + # Отправляем остальные куски с задержкой + for chunk in chunks[1:]: + await asyncio.sleep(SPLIT_MESSAGE_DELAY) + await message.answer(chunk, parse_mode=parse_mode) + + # Отправляем последнее сообщение с клавиатурой (если есть) + if reply_markup and len(chunks) > 1: + await message.answer("⬆️ Полный ответ ⬆️", reply_markup=reply_markup) + + return None + + +async def handle_message_with_retry(message: Message, text: str, keyboard=None): + """Функция для отправки сообщения с повторной попыткой без форматирования при ошибке""" + try: + # Пытаемся отправить сообщение с форматированием Markdown + await split_and_send_message(message, text, parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard) + except Exception as e: + error_text = str(e) + logging.error(f"Ошибка при отправке сообщения с форматированием: {error_text}") + + # Проверяем, связана ли ошибка с разбором сущностей (ошибка форматирования) + if "can't parse entities" in error_text or "entities" in error_text: + # Отправляем сообщение о проблеме с форматированием + await message.answer("⚠️ Возникла проблема с форматированием сообщения. Отправляю без форматирования.") + + # Отправляем сообщение без форматирования + await split_and_send_message(message, text, reply_markup=keyboard) + else: + # Если ошибка не связана с форматированием, просто пробрасываем её дальше + raise e + + @dp.message(Command("start")) async def cmd_start(message: Message): """Обработчик команды /start""" @@ -167,8 +222,8 @@ async def cmd_start(message: Message): # Загружаем контекст из БД при первом запуске messages = await load_messages_from_db(user_id) if not messages: - # Если сообщений нет, создаем новый список - user_messages[user_id] = [] + # Если сообщений нет, создаем новый список с начальным промтом + user_messages[user_id] = [{"role": "system", "content": INITIAL_PROMPT}] else: # Если есть, сохраняем их в кэше user_messages[user_id] = messages @@ -196,12 +251,15 @@ async def cmd_clear(message: Message): """Очистка контекста диалога""" user_id = message.from_user.id - # Очищаем контекст в памяти - user_messages[user_id] = [] + # Очищаем контекст в памяти и добавляем начальный промт + user_messages[user_id] = [{"role": "system", "content": INITIAL_PROMPT}] # Очищаем контекст в БД await clear_messages_from_db(user_id) + # Сохраняем начальный промт в БД + await save_message_to_db(user_id, user_messages[user_id][0]) + await message.answer("Контекст диалога очищен.") @@ -214,7 +272,19 @@ async def handle_photo(message: Message): if user_id not in user_messages: # Пытаемся загрузить контекст из БД messages = await load_messages_from_db(user_id) - user_messages[user_id] = messages or [] + if not messages: + # Если нет сообщений, создаем начальный промт + user_messages[user_id] = [{"role": "system", "content": INITIAL_PROMPT}] + # Сохраняем начальный промт в БД + await save_message_to_db(user_id, user_messages[user_id][0]) + else: + # Проверяем, есть ли начальный промт + if not messages or messages[0].get("role") != "system": + # Добавляем начальный промт в начало истории + messages.insert(0, {"role": "system", "content": INITIAL_PROMPT}) + # Сохраняем начальный промт в БД + await save_message_to_db(user_id, messages[0]) + user_messages[user_id] = messages # Получаем фото наилучшего качества photo = message.photo[-1] @@ -247,12 +317,17 @@ async def handle_photo(message: Message): try: # Получаем ответ от Gemma answer = "" + print("AI stream: ", end="", flush=True) + async for part in await ollama_client.chat( model=model_name, messages=user_messages[user_id], stream=True ): - answer += part['message']['content'] + content = part['message']['content'] + answer += content + # Логируем поток от ИИ без переноса строки и с flush=True + print(content, end="", flush=True) # Останавливаем задачу обновления статуса печати if user_id in typing_tasks: @@ -275,7 +350,7 @@ async def handle_photo(message: Message): # Отправляем ответ пользователю с Markdown-форматированием и кнопкой safe_answer = escape_markdown(answer) - await message.answer(safe_answer, parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard) + await handle_message_with_retry(message, safe_answer, keyboard) except Exception as e: # Останавливаем задачу обновления статуса печати в случае ошибки @@ -296,7 +371,19 @@ async def handle_text(message: Message): if user_id not in user_messages: # Пытаемся загрузить контекст из БД messages = await load_messages_from_db(user_id) - user_messages[user_id] = messages or [] + if not messages: + # Если нет сообщений, создаем начальный промт + user_messages[user_id] = [{"role": "system", "content": INITIAL_PROMPT}] + # Сохраняем начальный промт в БД + await save_message_to_db(user_id, user_messages[user_id][0]) + else: + # Проверяем, есть ли начальный промт + if not messages or messages[0].get("role") != "system": + # Добавляем начальный промт в начало истории + messages.insert(0, {"role": "system", "content": INITIAL_PROMPT}) + # Сохраняем начальный промт в БД + await save_message_to_db(user_id, messages[0]) + user_messages[user_id] = messages # Создаем сообщение пользователя user_message = {"role": "user", "content": message.text} @@ -313,12 +400,17 @@ async def handle_text(message: Message): try: # Получаем ответ от Gemma answer = "" + print("AI stream: ", end="", flush=True) + async for part in await ollama_client.chat( model=model_name, messages=user_messages[user_id], stream=True ): - answer += part['message']['content'] + content = part['message']['content'] + answer += content + # Логируем поток от ИИ без переноса строки и с flush=True + print(content, end="", flush=True) # Останавливаем задачу обновления статуса печати if user_id in typing_tasks: @@ -341,7 +433,7 @@ async def handle_text(message: Message): # Отправляем ответ пользователю с Markdown-форматированием и кнопкой safe_answer = escape_markdown(answer) - await message.answer(safe_answer, parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard) + await handle_message_with_retry(message, safe_answer, keyboard) except Exception as e: # Останавливаем задачу обновления статуса печати в случае ошибки @@ -365,12 +457,15 @@ async def clear_history_callback(callback: types.CallbackQuery): """Обработчик кнопки очистки истории""" user_id = callback.from_user.id - # Очищаем контекст в памяти - user_messages[user_id] = [] + # Очищаем контекст в памяти и добавляем начальный промт + user_messages[user_id] = [{"role": "system", "content": INITIAL_PROMPT}] # Очищаем контекст в БД await clear_messages_from_db(user_id) + # Сохраняем начальный промт в БД + await save_message_to_db(user_id, user_messages[user_id][0]) + # Уведомляем пользователя await callback.answer("Контекст диалога очищен!")