diff --git a/bot.py b/bot.py index c4a3848..486d5b2 100644 --- a/bot.py +++ b/bot.py @@ -4,6 +4,7 @@ import logging import json from io import BytesIO from typing import Dict, List, Any, Optional +import re from aiogram import Bot, Dispatcher, types, F from aiogram.filters import Command @@ -11,6 +12,7 @@ from aiogram.types import Message from dotenv import load_dotenv from ollama import AsyncClient import asyncpg +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton # Настройка логирования logging.basicConfig(level=logging.INFO) @@ -151,6 +153,50 @@ async def keep_typing(chat_id: int): logging.error(f"Ошибка в keep_typing: {e}") +def escape_markdown(text: str) -> str: + """Экранирует специальные символы Markdown, если они не являются частью форматирования""" + # Регулярное выражение для поиска уже отформатированного текста + md_patterns = [ + r'`[^`]+`', # Код + r'\*\*[^*]+\*\*', # Жирный + r'\*[^*]+\*', # Курсив + r'_[^_]+_', # Курсив (альтернативный) + r'__[^_]+__', # Жирный (альтернативный) + r'\[[^\]]+\]\([^)]+\)', # Ссылки + ] + + # Собираем все совпадения с шаблонами + matches = [] + for pattern in md_patterns: + matches.extend([(m.start(), m.end()) for m in re.finditer(pattern, text)]) + + # Сортируем по началу совпадения + matches.sort() + + # Если нет совпадений, просто экранируем все специальные символы + if not matches: + return re.sub(r'([_*\[\]()~`>#+-=|{}.!\\])', r'\\\1', text) + + # Иначе экранируем только те символы, которые не входят в уже отформатированный текст + result = "" + last_pos = 0 + + for start, end in matches: + # Экранируем символы до начала форматирования + if start > last_pos: + result += re.sub(r'([_*\[\]()~`>#+-=|{}.!\\])', r'\\\1', text[last_pos:start]) + + # Добавляем отформатированный текст без изменений + result += text[start:end] + last_pos = end + + # Экранируем символы после последнего форматирования + if last_pos < len(text): + result += re.sub(r'([_*\[\]()~`>#+-=|{}.!\\])', r'\\\1', text[last_pos:]) + + return result + + @dp.message(Command("start")) async def cmd_start(message: Message): """Обработчик команды /start""" @@ -165,7 +211,22 @@ async def cmd_start(message: Message): # Если есть, сохраняем их в кэше user_messages[user_id] = messages - await message.answer("Привет! Я бот на базе Gemma. Отправьте мне сообщение или фото, и я отвечу вам.") + welcome_message = ( + "Привет! Я бот на базе Gemma. Отправьте мне сообщение или фото, и я отвечу вам.\n\n" + "Поддерживается *Markdown* форматирование:\n" + "• *жирный текст* (звездочки)\n" + "• _курсив_ (нижнее подчеркивание)\n" + "• `код` (обратные кавычки)\n" + "• [ссылки](https://example.com)\n\n" + "Используйте /clear для очистки контекста диалога." + ) + + # Создаем инлайн-кнопку для очистки истории + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🗑 Очистить историю сообщений", callback_data="clear_history")] + ]) + + await message.answer(welcome_message, parse_mode=types.ParseMode.MARKDOWN, reply_markup=keyboard) @dp.message(Command("clear")) @@ -245,8 +306,14 @@ async def handle_photo(message: Message): # Сохраняем ответ в БД await save_message_to_db(user_id, assistant_message) - # Отправляем ответ пользователю - await message.answer(answer) + # Создаем инлайн-кнопку для очистки истории + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🗑 Очистить историю сообщений", callback_data="clear_history")] + ]) + + # Отправляем ответ пользователю с Markdown-форматированием и кнопкой + safe_answer = escape_markdown(answer) + await message.answer(safe_answer, parse_mode=types.ParseMode.MARKDOWN, reply_markup=keyboard) except Exception as e: # Останавливаем задачу обновления статуса печати в случае ошибки @@ -305,8 +372,14 @@ async def handle_text(message: Message): # Сохраняем ответ в БД await save_message_to_db(user_id, assistant_message) - # Отправляем ответ пользователю - await message.answer(answer) + # Создаем инлайн-кнопку для очистки истории + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🗑 Очистить историю сообщений", callback_data="clear_history")] + ]) + + # Отправляем ответ пользователю с Markdown-форматированием и кнопкой + safe_answer = escape_markdown(answer) + await message.answer(safe_answer, parse_mode=types.ParseMode.MARKDOWN, reply_markup=keyboard) except Exception as e: # Останавливаем задачу обновления статуса печати в случае ошибки @@ -324,6 +397,25 @@ async def handle_other(message: Message): await message.answer("Я могу обрабатывать только текст и фотографии. Пожалуйста, отправьте текст или одну фотографию.") +# Добавляем обработчик нажатия на кнопку очистки истории +@dp.callback_query(F.data == "clear_history") +async def clear_history_callback(callback: types.CallbackQuery): + """Обработчик кнопки очистки истории""" + user_id = callback.from_user.id + + # Очищаем контекст в памяти + user_messages[user_id] = [] + + # Очищаем контекст в БД + await clear_messages_from_db(user_id) + + # Уведомляем пользователя + await callback.answer("Контекст диалога очищен!") + + # Отправляем сообщение в чат + await callback.message.answer("Контекст диалога очищен. Вы можете начать новый разговор.") + + async def main(): """Запуск бота""" # Инициализируем подключение к базе данных