diff --git a/assistant_bot/agent.py b/assistant_bot/agent.py index 324d5cc..222e733 100644 --- a/assistant_bot/agent.py +++ b/assistant_bot/agent.py @@ -16,11 +16,15 @@ from .prompts import ASSISTANT_SYSTEM_PROMPT from .reminders import parse_reminder from .storage import AssistantStorage from .telegram_utils import ( + edit_markdown, edit_or_reply, + escape_markdown_text, get_ai_client, get_storage, get_tz, + markdown_code, parse_positive_int, + reply_markdown, require_user_id, ) from .time_utils import format_local_dt, to_utc_iso, utc_now @@ -80,12 +84,16 @@ def build_agent_tool_prompt(tz: ZoneInfo) -> str: "Ты работаешь как агент с внутренними tools. Пользователь пишет свободным текстом, " "а Telegram-команды ему не нужны.\n" f"Текущее локальное время: {now_local}. Таймзона: {tz.key}.\n\n" - "Отвечай СТРОГО одним JSON-объектом без Markdown и без текста вокруг.\n" + "Отвечай СТРОГО одним JSON-объектом без Markdown-блока и без текста вокруг.\n" "Если нужно выполнить действие, верни tool_calls. Если действие уже выполнено " "или tool не нужен, верни final.\n\n" "Форматы ответа:\n" '{"tool_calls":[{"name":"create_note","arguments":{"text":"..."}}],"reset_context":false}\n' '{"final":"Короткий ответ пользователю","reset_context":false}\n\n' + "Значение final оформляй обычным Markdown, не MarkdownV2. Умеренно используй " + "жирный и курсивный текст, списки, ссылки и блоки кода, когда они улучшают " + "читаемость. Для короткого простого ответа разметка не обязательна. " + "Не добавляй декоративные эмодзи чаще одного раза на сообщение.\n\n" "Доступные tools:\n" "- get_current_datetime {} - узнать текущее локальное и UTC-время.\n" '- remember {"text": string} - сохранить важный долгосрочный факт о пользователе.\n' @@ -389,7 +397,10 @@ async def run_agent_prompt( user_id = require_user_id(update) if user_id is None: - await update.effective_message.reply_text("Не могу определить пользователя.") + await reply_markdown( + update.effective_message, + "⚠️ **Не могу определить пользователя.**", + ) return storage = get_storage(context) @@ -428,8 +439,10 @@ async def run_agent_prompt( chat_id=chat_id, action=ChatAction.TYPING, ) - placeholder = await update.effective_message.reply_text( - f"Думаю через {ai_client.display_name} / {model}..." + placeholder = await reply_markdown( + update.effective_message, + f"💭 *Думаю через {escape_markdown_text(ai_client.display_name)} / " + f"{markdown_code(model)}…*", ) try: @@ -502,7 +515,7 @@ async def run_agent_prompt( "content": ( "Лимит tool-шагов исчерпан. Больше не вызывай tools. " f"Последние результаты tools: {json_dumps(last_tool_results)}. " - "Сформулируй короткий финальный ответ пользователю обычным текстом." + "Сформулируй короткий финальный ответ пользователю обычным Markdown." ), }, ], @@ -523,13 +536,20 @@ async def run_agent_prompt( hint = ( "Проверь YANDEX_CLOUD_FOLDER, YC_API_KEY и доступ к выбранной модели." ) - await placeholder.edit_text( - f"Не получилось вызвать {ai_client.display_name}.\n{exc}\n\n{hint}" + await edit_markdown( + placeholder, + f"⚠️ **Не получилось вызвать " + f"{escape_markdown_text(ai_client.display_name)}.**\n\n" + f"{escape_markdown_text(exc)}\n\n" + f"{escape_markdown_text(hint)}", ) return except Exception: logger.exception("Failed to run AI prompt") - await placeholder.edit_text("Произошла внутренняя ошибка при запросе к модели.") + await edit_markdown( + placeholder, + "⚠️ **Произошла внутренняя ошибка** при запросе к модели.", + ) async def run_ai_prompt( diff --git a/assistant_bot/auth.py b/assistant_bot/auth.py index 2807285..9da1ddd 100644 --- a/assistant_bot/auth.py +++ b/assistant_bot/auth.py @@ -4,7 +4,7 @@ from telegram import Update from telegram.constants import ChatType from telegram.ext import ApplicationHandlerStop, ContextTypes -from .telegram_utils import get_storage, require_user_id +from .telegram_utils import get_storage, reply_markdown, require_user_id def passwords_match(candidate: str, expected: str) -> bool: @@ -30,8 +30,9 @@ async def authentication_guard( chat = update.effective_chat if chat is not None and chat.type != ChatType.PRIVATE: if message: - await message.reply_text( - "Сначала авторизуйся в личном чате с ботом." + await reply_markdown( + message, + "🔐 **Сначала авторизуйся в личном чате с ботом.**", ) raise ApplicationHandlerStop @@ -39,18 +40,23 @@ async def authentication_guard( password = str(context.application.bot_data["assistant_password"]) if candidate and passwords_match(candidate, password): storage.authorize_user(user_id) - await message.reply_text( - "Пароль принят. Доступ открыт — повторно вводить его не нужно." + await reply_markdown( + message, + "✅ **Пароль принят.** Доступ открыт — повторно вводить его не нужно.", ) raise ApplicationHandlerStop if candidate and not candidate.startswith("/"): - await message.reply_text("Неверный пароль. Попробуй еще раз.") + await reply_markdown( + message, + "🔐 **Неверный пароль.** Попробуй ещё раз.", + ) raise ApplicationHandlerStop if message: - await message.reply_text( - "Для доступа к боту введи пароль одним текстовым сообщением." + await reply_markdown( + message, + "🔐 **Для доступа к боту введи пароль** одним текстовым сообщением.", ) elif update.inline_query: await update.inline_query.answer([], cache_time=0) diff --git a/assistant_bot/config.py b/assistant_bot/config.py index 76086e7..0c292ca 100644 --- a/assistant_bot/config.py +++ b/assistant_bot/config.py @@ -45,11 +45,6 @@ MAX_TELEGRAM_MESSAGE_LENGTH = 3900 MAX_AGENT_STEPS = 5 CONVERSATION_HISTORY_LIMIT = 12 -HTML_FORMATS = ( - ("Bold", "b"), - ("Italic", "i"), -) - def load_env_file(path: Path = ENV_FILE) -> None: if not path.exists(): diff --git a/assistant_bot/handlers.py b/assistant_bot/handlers.py index 4c24099..b4f79ef 100644 --- a/assistant_bot/handlers.py +++ b/assistant_bot/handlers.py @@ -16,13 +16,17 @@ from .reminders import parse_reminder from .telegram_utils import ( build_inline_results, command_text, + edit_markdown, + escape_markdown_text, get_ai_client, get_speech_recognizer, get_storage, get_tz, get_voice_max_duration, + markdown_code, parse_positive_int, reply_long, + reply_markdown, require_user_id, ) from .speech import SpeechRecognitionError @@ -44,30 +48,35 @@ def format_numbered_rows( async def start(update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: if update.message: - await update.message.reply_text( - "Я готов как персональный ассистент.\n" - "Пиши обычным текстом или отправляй голосовые сообщения: " - "'запомни, что...', 'напомни завтра в 10:00...', " - "'сохрани заметку...', 'покажи мои статусы'.\n" - "Я помню последние реплики диалога; команда /new очищает текущий контекст.\n" - "Я сам решу, когда нужно вызвать внутренний tool." + await reply_markdown( + update.message, + "**👋 Персональный ассистент готов**\n\n" + "Пиши обычным текстом или отправляй голосовые сообщения. Например:\n\n" + "- Запомни, что я предпочитаю короткие ответы\n" + "- Напомни завтра в 10:00 проверить почту\n" + "- Сохрани заметку с идеей проекта\n" + "- Покажи мои статусы\n\n" + "Я помню последние реплики диалога. Команда `/new` начинает новую тему, " + "не удаляя архив переписки.", ) async def help_command(update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: if update.message: - await update.message.reply_text( - "Основной режим - свободный диалог текстом или голосовыми сообщениями.\n\n" - "Примеры:\n" - "Запомни, что я предпочитаю короткие ответы.\n" - "Сохрани заметку: идея для проекта.\n" - "Напомни через 30 минут проверить сборку.\n" - "Отслеживай паспорт, статус: жду ответа.\n" - "Покажи активные напоминания.\n\n" - "Вся переписка сохраняется. /history тема ищет старое обсуждение, " - "/new начинает новый контекст без удаления архива.\n\n" - "Служебные команды оставлены для настройки и отладки: /models, /model, /ask.\n" - "Режим выбирается через ASSISTANT_MODE=local или ASSISTANT_MODE=yandex." + await reply_markdown( + update.message, + "**🧭 Как пользоваться ботом**\n\n" + "Основной режим — свободный диалог текстом или голосовыми сообщениями.\n\n" + "**Примеры запросов**\n\n" + "- Запомни, что я предпочитаю короткие ответы\n" + "- Сохрани заметку: идея для проекта\n" + "- Напомни через 30 минут проверить сборку\n" + "- Отслеживай паспорт, статус: жду ответа\n" + "- Покажи активные напоминания\n\n" + "Вся переписка сохраняется. `/history тема` ищет старое обсуждение, " + "а `/new` начинает новый контекст без удаления архива.\n\n" + "**Служебные команды:** `/models`, `/model`, `/ask`\n" + "**Режим:** `ASSISTANT_MODE=local` или `ASSISTANT_MODE=yandex`", ) @@ -76,10 +85,13 @@ async def new_conversation_command(update: Update, context: ContextTypes.DEFAULT return user_id = require_user_id(update) if user_id is None: - await update.message.reply_text("Не могу определить пользователя.") + await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return get_storage(context).start_new_conversation(user_id, int(update.message.chat_id)) - await update.message.reply_text("Начинаем новую тему. Предыдущая переписка сохранена в архиве.") + await reply_markdown( + update.message, + "🆕 **Начинаем новую тему.** Предыдущая переписка сохранена в архиве.", + ) async def history_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -87,12 +99,15 @@ async def history_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> return user_id = require_user_id(update) if user_id is None: - await update.message.reply_text("Не могу определить пользователя.") + await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return query = command_text(context) if not query: - await update.message.reply_text("Укажи тему или ключевые слова: /history отпуск") + await reply_markdown( + update.message, + "🔎 Укажи тему или ключевые слова: `/history отпуск`", + ) return rows = get_storage(context).search_conversation_messages( @@ -102,14 +117,18 @@ async def history_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> limit=10, ) if not rows: - await update.message.reply_text("В сохраненной переписке ничего не найдено.") + await reply_markdown( + update.message, + "🔎 **В сохранённой переписке ничего не найдено.**", + ) return role_names = {"user": "Вы", "assistant": "Бот"} tz = get_tz(context) - text = "Найденные сообщения:\n\n" + "\n\n".join( - f"{role_names.get(str(row['role']), row['role'])} · " - f"{format_local_dt(row['created_at'], tz)}\n{row['content']}" + text = "**🔎 Найденные сообщения**\n\n" + "\n\n".join( + f"**{escape_markdown_text(role_names.get(str(row['role']), row['role']))}** · " + f"{markdown_code(format_local_dt(row['created_at'], tz))}\n" + f"{str(row['content']) if row['role'] == 'assistant' else escape_markdown_text(row['content'])}" for row in rows ) await reply_long(update.message, text) @@ -119,7 +138,10 @@ async def ask_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non prompt = command_text(context) if not prompt: if update.message: - await update.message.reply_text("Напиши текст после /ask или просто отправь сообщение в личный чат.") + await reply_markdown( + update.message, + "💬 Напиши текст после `/ask` или просто отправь сообщение в личный чат.", + ) return await run_agent_prompt(update, context, prompt) @@ -137,8 +159,10 @@ async def private_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N voice = update.message.voice max_duration = get_voice_max_duration(context) if voice.duration > max_duration: - await update.message.reply_text( - f"Голосовое сообщение слишком длинное. Максимум: {max_duration} сек." + await reply_markdown( + update.message, + "🎙️ **Голосовое сообщение слишком длинное.** " + f"Максимум: {markdown_code(max_duration)} сек.", ) return @@ -146,7 +170,10 @@ async def private_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N chat_id=int(update.message.chat_id), action=ChatAction.TYPING, ) - status_message = await update.message.reply_text("Распознаю голосовое сообщение...") + status_message = await reply_markdown( + update.message, + "🎙️ *Распознаю голосовое сообщение…*", + ) temp_path: Path | None = None transcript = "" @@ -163,14 +190,17 @@ async def private_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N ) except (SpeechRecognitionError, TelegramError): logger.exception("Failed to transcribe Telegram voice message") - await status_message.edit_text( - "Не получилось распознать голосовое сообщение. Попробуй еще раз позже." + await edit_markdown( + status_message, + "⚠️ **Не получилось распознать голосовое сообщение.** " + "Попробуй ещё раз позже.", ) return except Exception: logger.exception("Unexpected error while processing Telegram voice message") - await status_message.edit_text( - "Произошла внутренняя ошибка при обработке голосового сообщения." + await edit_markdown( + status_message, + "⚠️ **Произошла внутренняя ошибка** при обработке голосового сообщения.", ) return finally: @@ -181,11 +211,17 @@ async def private_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N logger.warning("Could not delete temporary voice file %s", temp_path) if not transcript: - await status_message.edit_text("Не удалось расслышать речь в сообщении.") + await edit_markdown( + status_message, + "⚠️ **Не удалось расслышать речь в сообщении.**", + ) return preview = transcript if len(transcript) <= 500 else f"{transcript[:497]}..." - await status_message.edit_text(f"Распознано: {preview}") + await edit_markdown( + status_message, + f"🎙️ **Распознано:** {escape_markdown_text(preview)}", + ) await run_agent_prompt(update, context, transcript) @@ -197,20 +233,26 @@ async def models_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> try: models = await ai_client.list_models() except AIClientError as exc: - await update.message.reply_text( - f"Не получилось получить список моделей {ai_client.display_name}.\n{exc}" + await reply_markdown( + update.message, + "⚠️ **Не получилось получить список моделей " + f"{escape_markdown_text(ai_client.display_name)}.**\n\n" + f"{escape_markdown_text(exc)}", ) return if not models: - await update.message.reply_text( - f"{ai_client.display_name} доступна, но моделей не найдено." + await reply_markdown( + update.message, + f"🤖 **{escape_markdown_text(ai_client.display_name)} доступна, " + "но моделей не найдено.**", ) return - await update.message.reply_text( - f"Модели {ai_client.display_name}:\n" - + "\n".join(f"- {model}" for model in models) + await reply_markdown( + update.message, + f"**🤖 Модели {escape_markdown_text(ai_client.display_name)}**\n\n" + + "\n".join(f"- {markdown_code(model)}" for model in models), ) @@ -220,23 +262,26 @@ async def model_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N user_id = require_user_id(update) if user_id is None: - await update.message.reply_text("Не могу определить пользователя.") + await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return storage = get_storage(context) ai_client = get_ai_client(context) model = command_text(context) if not model: - await update.message.reply_text( - f"Текущая модель {ai_client.display_name}: " - f"{ai_client.normalize_model(storage.get_user_model(user_id, ai_client.provider))}" + await reply_markdown( + update.message, + f"🤖 **Текущая модель {escape_markdown_text(ai_client.display_name)}:** " + f"{markdown_code(ai_client.normalize_model(storage.get_user_model(user_id, ai_client.provider)))}", ) return model = ai_client.normalize_model(model) storage.set_user_model(user_id, model, ai_client.provider) - await update.message.reply_text( - f"Модель {ai_client.display_name} сохранена: {model}" + await reply_markdown( + update.message, + f"✅ **Модель {escape_markdown_text(ai_client.display_name)} сохранена:** " + f"{markdown_code(model)}", ) @@ -247,14 +292,21 @@ async def remember_command(update: Update, context: ContextTypes.DEFAULT_TYPE) - user_id = require_user_id(update) text = command_text(context) if user_id is None: - await update.message.reply_text("Не могу определить пользователя.") + await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return if not text: - await update.message.reply_text("Напиши факт после команды: /remember я предпочитаю короткие ответы") + await reply_markdown( + update.message, + "🧠 Напиши факт после команды: " + "`/remember я предпочитаю короткие ответы`", + ) return memory_id = get_storage(context).add_memory(user_id, text) - await update.message.reply_text(f"Запомнил. ID памяти: {memory_id}") + await reply_markdown( + update.message, + f"✅ **Запомнил.** ID памяти: {markdown_code(memory_id)}", + ) async def memory_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -263,14 +315,17 @@ async def memory_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> user_id = require_user_id(update) if user_id is None: - await update.message.reply_text("Не могу определить пользователя.") + await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return rows = get_storage(context).list_memories(user_id) - text = format_numbered_rows( + text = "**🧠 Долговременная память**\n\n" + format_numbered_rows( rows, - lambda row: f"#{row['id']} - {row['text']}", - "Долговременная память пока пустая.", + lambda row: ( + f"- {markdown_code('#' + str(row['id']))} " + f"{escape_markdown_text(row['text'])}" + ), + "*Пока пусто.*", ) await reply_long(update.message, text) @@ -282,14 +337,22 @@ async def forget_memory_command(update: Update, context: ContextTypes.DEFAULT_TY user_id = require_user_id(update) memory_id = parse_positive_int(command_text(context)) if user_id is None: - await update.message.reply_text("Не могу определить пользователя.") + await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return if memory_id is None: - await update.message.reply_text("Укажи ID: /forget_memory 3") + await reply_markdown( + update.message, + "🧠 Укажи ID: `/forget_memory 3`", + ) return deleted = get_storage(context).delete_memory(user_id, memory_id) - await update.message.reply_text("Удалил." if deleted else "Не нашел такую запись памяти.") + await reply_markdown( + update.message, + "✅ **Запись памяти удалена.**" + if deleted + else "🔎 **Не нашёл такую запись памяти.**", + ) async def note_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -299,14 +362,20 @@ async def note_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No user_id = require_user_id(update) text = command_text(context) if user_id is None: - await update.message.reply_text("Не могу определить пользователя.") + await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return if not text: - await update.message.reply_text("Напиши текст заметки: /note идея для проекта") + await reply_markdown( + update.message, + "📝 Напиши текст заметки: `/note идея для проекта`", + ) return note_id = get_storage(context).add_note(user_id, text) - await update.message.reply_text(f"Заметка сохранена. ID: {note_id}") + await reply_markdown( + update.message, + f"✅ **Заметка сохранена.** ID: {markdown_code(note_id)}", + ) async def notes_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -315,14 +384,17 @@ async def notes_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N user_id = require_user_id(update) if user_id is None: - await update.message.reply_text("Не могу определить пользователя.") + await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return rows = get_storage(context).list_notes(user_id) - text = format_numbered_rows( + text = "**📝 Заметки**\n\n" + format_numbered_rows( rows, - lambda row: f"#{row['id']} - {row['text']}", - "Заметок пока нет.", + lambda row: ( + f"- {markdown_code('#' + str(row['id']))} " + f"{escape_markdown_text(row['text'])}" + ), + "*Пока пусто.*", ) await reply_long(update.message, text) @@ -334,14 +406,17 @@ async def forget_note_command(update: Update, context: ContextTypes.DEFAULT_TYPE user_id = require_user_id(update) note_id = parse_positive_int(command_text(context)) if user_id is None: - await update.message.reply_text("Не могу определить пользователя.") + await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return if note_id is None: - await update.message.reply_text("Укажи ID: /forget 3") + await reply_markdown(update.message, "📝 Укажи ID: `/forget 3`") return deleted = get_storage(context).delete_note(user_id, note_id) - await update.message.reply_text("Удалил." if deleted else "Не нашел такую заметку.") + await reply_markdown( + update.message, + "✅ **Заметка удалена.**" if deleted else "🔎 **Не нашёл такую заметку.**", + ) async def remind_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -350,17 +425,18 @@ async def remind_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> user_id = require_user_id(update) if user_id is None: - await update.message.reply_text("Не могу определить пользователя.") + await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return parsed = parse_reminder(command_text(context), get_tz(context)) if not parsed: - await update.message.reply_text( - "Формат напоминания:\n" - "/remind 30m позвонить\n" - "/remind через 2 часа проверить статус\n" - "/remind 18:30 отправить отчет\n" - "/remind 2026-07-16 18:30 отправить отчет" + await reply_markdown( + update.message, + "**⏰ Формат напоминания**\n\n" + "- `/remind 30m позвонить`\n" + "- `/remind через 2 часа проверить статус`\n" + "- `/remind 18:30 отправить отчёт`\n" + "- `/remind 2026-07-16 18:30 отправить отчёт`", ) return @@ -370,9 +446,10 @@ async def remind_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> text=parsed.text, remind_at_utc=parsed.remind_at_utc, ) - await update.message.reply_text( - f"Напоминание #{reminder_id} поставлено на " - f"{format_local_dt(parsed.remind_at_utc, get_tz(context))}." + await reply_markdown( + update.message, + f"✅ **Напоминание {markdown_code(f'#{reminder_id}')} поставлено на** " + f"{markdown_code(format_local_dt(parsed.remind_at_utc, get_tz(context)))}.", ) @@ -382,15 +459,19 @@ async def reminders_command(update: Update, context: ContextTypes.DEFAULT_TYPE) user_id = require_user_id(update) if user_id is None: - await update.message.reply_text("Не могу определить пользователя.") + await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return tz = get_tz(context) rows = get_storage(context).list_reminders(user_id) - text = format_numbered_rows( + text = "**⏰ Активные напоминания**\n\n" + format_numbered_rows( rows, - lambda row: f"#{row['id']} - {format_local_dt(row['remind_at'], tz)} - {row['text']}", - "Активных напоминаний нет.", + lambda row: ( + f"- {markdown_code('#' + str(row['id']))} " + f"{markdown_code(format_local_dt(row['remind_at'], tz))} — " + f"{escape_markdown_text(row['text'])}" + ), + "*Активных напоминаний нет.*", ) await reply_long(update.message, text) @@ -402,14 +483,22 @@ async def cancel_reminder_command(update: Update, context: ContextTypes.DEFAULT_ user_id = require_user_id(update) reminder_id = parse_positive_int(command_text(context)) if user_id is None: - await update.message.reply_text("Не могу определить пользователя.") + await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return if reminder_id is None: - await update.message.reply_text("Укажи ID: /cancelreminder 3") + await reply_markdown( + update.message, + "⏰ Укажи ID: `/cancelreminder 3`", + ) return cancelled = get_storage(context).cancel_reminder(user_id, reminder_id) - await update.message.reply_text("Отменил." if cancelled else "Не нашел активное напоминание.") + await reply_markdown( + update.message, + "✅ **Напоминание отменено.**" + if cancelled + else "🔎 **Не нашёл активное напоминание.**", + ) def split_tracking_text(raw: str) -> tuple[str, str]: @@ -426,19 +515,30 @@ async def track_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N user_id = require_user_id(update) raw_text = command_text(context) if user_id is None: - await update.message.reply_text("Не могу определить пользователя.") + await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return if not raw_text: - await update.message.reply_text("Формат: /track паспорт | жду ответа") + await reply_markdown( + update.message, + "📌 Формат: `/track паспорт | жду ответа`", + ) return title, status = split_tracking_text(raw_text) if not title: - await update.message.reply_text("Название не должно быть пустым.") + await reply_markdown( + update.message, + "⚠️ **Название не должно быть пустым.**", + ) return item_id = get_storage(context).add_tracked_item(user_id, title, status) - await update.message.reply_text(f"Добавил отслеживание #{item_id}: {title} - {status}") + await reply_markdown( + update.message, + f"✅ **Добавил отслеживание {markdown_code(f'#{item_id}')}**\n\n" + f"**Название:** {escape_markdown_text(title)}\n" + f"**Статус:** {escape_markdown_text(status)}", + ) async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -447,17 +547,21 @@ async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> user_id = require_user_id(update) if user_id is None: - await update.message.reply_text("Не могу определить пользователя.") + await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return raw_text = command_text(context) storage = get_storage(context) if not raw_text: rows = storage.list_tracked_items(user_id) - text = format_numbered_rows( + text = "**📌 Отслеживаемые статусы**\n\n" + format_numbered_rows( rows, - lambda row: f"#{row['id']} - {row['title']}: {row['status']}", - "Нет отслеживаемых статусов.", + lambda row: ( + f"- {markdown_code('#' + str(row['id']))} " + f"**{escape_markdown_text(row['title'])}:** " + f"{escape_markdown_text(row['status'])}" + ), + "*Нет отслеживаемых статусов.*", ) await reply_long(update.message, text) return @@ -466,11 +570,17 @@ async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> item_id = parse_positive_int(parts[0]) new_status = parts[1].strip() if len(parts) > 1 else "" if item_id is None or not new_status: - await update.message.reply_text("Формат: /status 3 новый статус") + await reply_markdown( + update.message, + "📌 Формат: `/status 3 новый статус`", + ) return updated = storage.set_tracked_status(user_id, item_id, new_status) - await update.message.reply_text("Статус обновлен." if updated else "Не нашел такой объект.") + await reply_markdown( + update.message, + "✅ **Статус обновлён.**" if updated else "🔎 **Не нашёл такой объект.**", + ) async def untrack_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -480,14 +590,17 @@ async def untrack_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> user_id = require_user_id(update) item_id = parse_positive_int(command_text(context)) if user_id is None: - await update.message.reply_text("Не могу определить пользователя.") + await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return if item_id is None: - await update.message.reply_text("Укажи ID: /untrack 3") + await reply_markdown(update.message, "📌 Укажи ID: `/untrack 3`") return deleted = get_storage(context).delete_tracked_item(user_id, item_id) - await update.message.reply_text("Удалил." if deleted else "Не нашел такой объект.") + await reply_markdown( + update.message, + "✅ **Объект удалён.**" if deleted else "🔎 **Не нашёл такой объект.**", + ) async def inline_query(update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: diff --git a/assistant_bot/jobs.py b/assistant_bot/jobs.py index ae794cf..72598a5 100644 --- a/assistant_bot/jobs.py +++ b/assistant_bot/jobs.py @@ -7,6 +7,7 @@ from telegram.ext import Application from .config import REMINDER_POLL_SECONDS from .storage import AssistantStorage +from .telegram_utils import escape_markdown_text, markdown_code, send_markdown from .time_utils import format_local_dt, utc_now @@ -21,12 +22,15 @@ async def reminder_loop(application: Application) -> None: try: due_reminders = storage.due_reminders(utc_now()) for reminder in due_reminders: - await application.bot.send_message( + await send_markdown( + application.bot, chat_id=reminder["chat_id"], - text=( - f"Напоминание #{reminder['id']} " - f"({format_local_dt(reminder['remind_at'], tz)}):\n" - f"{reminder['text']}" + markdown=( + f"**⏰ Напоминание " + f"{markdown_code('#' + str(reminder['id']))}**\n\n" + f"**Время:** " + f"{markdown_code(format_local_dt(reminder['remind_at'], tz))}\n" + f"**Задача:** {escape_markdown_text(reminder['text'])}" ), ) storage.mark_reminder_sent(int(reminder["id"])) diff --git a/assistant_bot/telegram_utils.py b/assistant_bot/telegram_utils.py index 6155a3d..acb0b54 100644 --- a/assistant_bot/telegram_utils.py +++ b/assistant_bot/telegram_utils.py @@ -1,38 +1,215 @@ -from html import escape +import logging +import re from uuid import uuid4 from zoneinfo import ZoneInfo -from telegram import InlineQueryResultArticle, InputTextMessageContent, Message, Update +from telegram import Bot, InlineQueryResultArticle, InputTextMessageContent, Message, Update from telegram.constants import ParseMode +from telegram.error import BadRequest from telegram.ext import ContextTypes +from telegramify_markdown import ( + convert, + entities_to_markdownv2, + split_entities, + utf16_len, +) +from telegramify_markdown.config import RenderConfig from .ai import AIClient -from .config import HTML_FORMATS, MAX_TELEGRAM_MESSAGE_LENGTH +from .config import MAX_TELEGRAM_MESSAGE_LENGTH from .speech import SpeechTranscriber from .storage import AssistantStorage +logger = logging.getLogger(__name__) + +FORMATTING_FALLBACK_NOTICE = "⚠️ Не удалось применить форматирование." +_MARKDOWN_SPECIAL_CHARS = re.compile(r"([\\`*_[\]{}()#+\-.!|>~])") +_RENDER_CONFIG = RenderConfig() +_RENDER_CONFIG.markdown_symbol.heading_level_1 = "" +_RENDER_CONFIG.markdown_symbol.heading_level_2 = "" +_RENDER_CONFIG.markdown_symbol.heading_level_3 = "" +_RENDER_CONFIG.markdown_symbol.heading_level_4 = "" + + +def escape_markdown_text(value: object) -> str: + """Escape dynamic text before embedding it into ordinary Markdown.""" + return _MARKDOWN_SPECIAL_CHARS.sub(r"\\\1", str(value)) + + +def markdown_code(value: object) -> str: + """Render a dynamic value as a safe CommonMark inline-code span.""" + text = str(value) + longest_run = max((len(run) for run in re.findall(r"`+", text)), default=0) + delimiter = "`" * (longest_run + 1) + padding = " " if text.startswith(("`", " ")) or text.endswith(("`", " ")) else "" + return f"{delimiter}{padding}{text}{padding}{delimiter}" + + +def render_markdown_chunks(markdown: str) -> list[tuple[str, str]]: + """Return (MarkdownV2, plain text) pairs that fit Telegram's limit.""" + plain_text, entities = convert( + markdown, + latex_escape=False, + config=_RENDER_CONFIG, + ) + pending = list( + split_entities( + plain_text, + entities, + max_utf16_len=MAX_TELEGRAM_MESSAGE_LENGTH, + ) + ) + chunks: list[tuple[str, str]] = [] + + while pending: + chunk_text, chunk_entities = pending.pop(0) + markdown_v2 = entities_to_markdownv2(chunk_text, chunk_entities) + if utf16_len(markdown_v2) <= MAX_TELEGRAM_MESSAGE_LENGTH: + chunks.append((markdown_v2, chunk_text)) + continue + + plain_length = utf16_len(chunk_text) + if plain_length <= 1: + raise ValueError("Unable to split MarkdownV2 within Telegram's limit") + + sub_limit = max(1, plain_length // 2) + sub_chunks = list( + split_entities( + chunk_text, + chunk_entities, + max_utf16_len=sub_limit, + ) + ) + if len(sub_chunks) == 1: + sub_chunks = list( + split_entities( + chunk_text, + chunk_entities, + max_utf16_len=max(1, plain_length - 1), + ) + ) + if len(sub_chunks) == 1: + raise ValueError("Unable to split MarkdownV2 within Telegram's limit") + pending = sub_chunks + pending + + return chunks + + +def _plain_chunks(text: str) -> list[str]: + chunks = split_entities( + text, + [], + max_utf16_len=MAX_TELEGRAM_MESSAGE_LENGTH, + ) + return [chunk_text for chunk_text, _entities in chunks] or [text] + + +def _prepare_chunks(markdown: str) -> tuple[list[tuple[str, str]], bool]: + try: + chunks = render_markdown_chunks(markdown) + except Exception: + logger.exception("Failed to convert Markdown to Telegram MarkdownV2") + return [(chunk, chunk) for chunk in _plain_chunks(markdown)], True + return chunks or [("", "")], False + + +def _with_fallback_notice(text: str) -> str: + return f"{text}\n\n{FORMATTING_FALLBACK_NOTICE}" + + +def _with_markdown_notice(markdown_v2: str) -> str: + notice = entities_to_markdownv2(FORMATTING_FALLBACK_NOTICE, []) + return f"{markdown_v2}\n\n{notice}" + + +async def reply_markdown(message: Message, markdown: str) -> Message: + chunks, formatting_failed = _prepare_chunks(markdown) + first_message: Message | None = None + + for index, (markdown_v2, plain_text) in enumerate(chunks): + is_last = index == len(chunks) - 1 + formatted_text = ( + _with_markdown_notice(markdown_v2) + if is_last and formatting_failed + else markdown_v2 + ) + try: + sent = await message.reply_text( + formatted_text, + parse_mode=ParseMode.MARKDOWN_V2, + ) + except BadRequest: + logger.warning("Telegram rejected MarkdownV2; retrying as plain text") + formatting_failed = True + fallback_text = ( + _with_fallback_notice(plain_text) if is_last else plain_text + ) + sent = await message.reply_text(fallback_text) + if first_message is None: + first_message = sent + + assert first_message is not None + return first_message + + +async def edit_markdown(message: Message, markdown: str) -> None: + await edit_or_reply(message, markdown) + + +async def send_markdown(bot: Bot, chat_id: int, markdown: str) -> Message: + chunks, formatting_failed = _prepare_chunks(markdown) + first_message: Message | None = None + + for index, (markdown_v2, plain_text) in enumerate(chunks): + is_last = index == len(chunks) - 1 + formatted_text = ( + _with_markdown_notice(markdown_v2) + if is_last and formatting_failed + else markdown_v2 + ) + try: + sent = await bot.send_message( + chat_id=chat_id, + text=formatted_text, + parse_mode=ParseMode.MARKDOWN_V2, + ) + except BadRequest: + logger.warning("Telegram rejected MarkdownV2; retrying as plain text") + formatting_failed = True + fallback_text = ( + _with_fallback_notice(plain_text) if is_last else plain_text + ) + sent = await bot.send_message(chat_id=chat_id, text=fallback_text) + if first_message is None: + first_message = sent + + assert first_message is not None + return first_message + + def make_article( title: str, - text: str, - parse_mode: str | None = None, + markdown: str, ) -> InlineQueryResultArticle: + markdown_v2 = render_markdown_chunks(markdown)[0][0] return InlineQueryResultArticle( id=f"inline_{uuid4()}", title=title, - input_message_content=InputTextMessageContent(text, parse_mode=parse_mode), + input_message_content=InputTextMessageContent( + markdown_v2, + parse_mode=ParseMode.MARKDOWN_V2, + ), ) def build_inline_results(query: str) -> list[InlineQueryResultArticle]: - escaped_query = escape(query) + escaped_query = escape_markdown_text(query) return [ - make_article("Caps", query.upper()), - *[ - make_article(title, f"<{tag}>{escaped_query}", ParseMode.HTML) - for title, tag in HTML_FORMATS - ], + make_article("Caps", escape_markdown_text(query.upper())), + make_article("Bold", f"**{escaped_query}**"), + make_article("Italic", f"*{escaped_query}*"), ] @@ -68,24 +245,41 @@ def require_user_id(update: Update) -> int | None: async def reply_long(message: Message, text: str) -> None: - chunks = [ - text[index : index + MAX_TELEGRAM_MESSAGE_LENGTH] - for index in range(0, len(text), MAX_TELEGRAM_MESSAGE_LENGTH) - ] or [""] - - for chunk in chunks: - await message.reply_text(chunk) + await reply_markdown(message, text) async def edit_or_reply(message: Message, text: str) -> None: - chunks = [ - text[index : index + MAX_TELEGRAM_MESSAGE_LENGTH] - for index in range(0, len(text), MAX_TELEGRAM_MESSAGE_LENGTH) - ] or [""] + chunks, formatting_failed = _prepare_chunks(text) - await message.edit_text(chunks[0]) - for chunk in chunks[1:]: - await message.reply_text(chunk) + for index, (markdown_v2, plain_text) in enumerate(chunks): + is_first = index == 0 + is_last = index == len(chunks) - 1 + formatted_text = ( + _with_markdown_notice(markdown_v2) + if is_last and formatting_failed + else markdown_v2 + ) + try: + if is_first: + await message.edit_text( + formatted_text, + parse_mode=ParseMode.MARKDOWN_V2, + ) + else: + await message.reply_text( + formatted_text, + parse_mode=ParseMode.MARKDOWN_V2, + ) + except BadRequest: + logger.warning("Telegram rejected MarkdownV2; retrying as plain text") + formatting_failed = True + fallback_text = ( + _with_fallback_notice(plain_text) if is_last else plain_text + ) + if is_first: + await message.edit_text(fallback_text) + else: + await message.reply_text(fallback_text) def parse_positive_int(value: str) -> int | None: diff --git a/requirements-common.txt b/requirements-common.txt index acbc4c7..b04bcd6 100644 --- a/requirements-common.txt +++ b/requirements-common.txt @@ -1 +1,2 @@ python-telegram-bot==22.8 +telegramify-markdown==1.2.0 diff --git a/tests/test_auth.py b/tests/test_auth.py index eb568dc..71ba342 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -5,11 +5,12 @@ from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock -from telegram.constants import ChatType +from telegram.constants import ChatType, ParseMode from telegram.ext import ApplicationHandlerStop from assistant_bot.auth import authentication_guard from assistant_bot.storage import AssistantStorage +from assistant_bot.telegram_utils import render_markdown_chunks class AuthenticationGuardTests(unittest.IsolatedAsyncioTestCase): @@ -47,8 +48,12 @@ class AuthenticationGuardTests(unittest.IsolatedAsyncioTestCase): await authentication_guard(update, context) self.assertTrue(self.storage.is_user_authorized(42)) + rendered = render_markdown_chunks( + "✅ **Пароль принят.** Доступ открыт — повторно вводить его не нужно." + )[0][0] update.effective_message.reply_text.assert_awaited_once_with( - "Пароль принят. Доступ открыт — повторно вводить его не нужно." + rendered, + parse_mode=ParseMode.MARKDOWN_V2, ) async def test_wrong_password_does_not_authorize_user(self) -> None: @@ -58,8 +63,12 @@ class AuthenticationGuardTests(unittest.IsolatedAsyncioTestCase): await authentication_guard(update, context) self.assertFalse(self.storage.is_user_authorized(42)) + rendered = render_markdown_chunks( + "🔐 **Неверный пароль.** Попробуй ещё раз." + )[0][0] update.effective_message.reply_text.assert_awaited_once_with( - "Неверный пароль. Попробуй еще раз." + rendered, + parse_mode=ParseMode.MARKDOWN_V2, ) async def test_authorized_user_passes_guard(self) -> None: diff --git a/tests/test_telegram_utils.py b/tests/test_telegram_utils.py new file mode 100644 index 0000000..5bba4c1 --- /dev/null +++ b/tests/test_telegram_utils.py @@ -0,0 +1,116 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from telegram.constants import ParseMode +from telegram.error import BadRequest + +from assistant_bot.telegram_utils import ( + FORMATTING_FALLBACK_NOTICE, + build_inline_results, + escape_markdown_text, + render_markdown_chunks, + reply_markdown, +) + + +class MarkdownRenderingTests(unittest.TestCase): + def test_converts_common_markdown_to_markdown_v2(self) -> None: + chunks = render_markdown_chunks( + "**📝 Заметки**\n\n- `#3` Проект\\_v2\\.0" + ) + + self.assertEqual(len(chunks), 1) + markdown_v2, plain_text = chunks[0] + self.assertIn("*📝 Заметки*", markdown_v2) + self.assertIn("Проект\\_v2\\.0", markdown_v2) + self.assertNotIn("**", markdown_v2) + self.assertIn("Проект_v2.0", plain_text) + + def test_splits_long_formatted_text_without_losing_format(self) -> None: + chunks = render_markdown_chunks(f"**{'слово ' * 999}слово**") + + self.assertGreater(len(chunks), 1) + for markdown_v2, plain_text in chunks: + self.assertLessEqual(len(plain_text.encode("utf-16-le")) // 2, 3900) + self.assertLessEqual(len(markdown_v2.encode("utf-16-le")) // 2, 3900) + self.assertTrue(markdown_v2.startswith("*")) + self.assertTrue(markdown_v2.rstrip().endswith("*")) + + def test_splits_by_rendered_markdown_v2_length(self) -> None: + chunks = render_markdown_chunks(escape_markdown_text("_" * 5000)) + + self.assertGreater(len(chunks), 2) + for markdown_v2, _plain_text in chunks: + self.assertLessEqual(len(markdown_v2.encode("utf-16-le")) // 2, 3900) + + def test_inline_results_use_markdown_v2(self) -> None: + results = build_inline_results("проект_v2") + + bold_content = results[1].input_message_content + self.assertEqual(bold_content.parse_mode, ParseMode.MARKDOWN_V2) + self.assertEqual(bold_content.message_text, "*проект\\_v2*") + + +class MarkdownDeliveryTests(unittest.IsolatedAsyncioTestCase): + async def test_retries_plain_text_with_notice_when_telegram_rejects_markup( + self, + ) -> None: + fallback_message = SimpleNamespace() + message = SimpleNamespace( + reply_text=AsyncMock( + side_effect=[ + BadRequest("Can't parse entities"), + fallback_message, + ] + ) + ) + + sent = await reply_markdown(message, "**Готово.**") + + self.assertIs(sent, fallback_message) + self.assertEqual(message.reply_text.await_count, 2) + formatted_call, fallback_call = message.reply_text.await_args_list + self.assertEqual( + formatted_call.kwargs["parse_mode"], + ParseMode.MARKDOWN_V2, + ) + self.assertNotIn("parse_mode", fallback_call.kwargs) + self.assertEqual( + fallback_call.args[0], + f"Готово.\n\n{FORMATTING_FALLBACK_NOTICE}", + ) + + async def test_places_fallback_notice_only_in_last_chunk(self) -> None: + first_message = SimpleNamespace() + markdown = f"**{'слово ' * 999}слово**" + expected_chunks = render_markdown_chunks(markdown) + call_number = 0 + + async def send(*_args, **_kwargs): + nonlocal call_number + call_number += 1 + if call_number == 1: + raise BadRequest("Can't parse entities") + return first_message if call_number == 2 else SimpleNamespace() + + message = SimpleNamespace(reply_text=AsyncMock(side_effect=send)) + + await reply_markdown(message, markdown) + + self.assertEqual(message.reply_text.await_count, len(expected_chunks) + 1) + first_plain_call = message.reply_text.await_args_list[1] + last_formatted_call = message.reply_text.await_args_list[-1] + self.assertNotIn(FORMATTING_FALLBACK_NOTICE, first_plain_call.args[0]) + self.assertIn( + "Не удалось применить форматирование", + last_formatted_call.args[0], + ) + self.assertEqual( + last_formatted_call.kwargs["parse_mode"], + ParseMode.MARKDOWN_V2, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_voice_handler.py b/tests/test_voice_handler.py index 8e8bbee..a0ee93b 100644 --- a/tests/test_voice_handler.py +++ b/tests/test_voice_handler.py @@ -4,7 +4,10 @@ from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +from telegram.constants import ParseMode + from assistant_bot.handlers import private_voice +from assistant_bot.telegram_utils import render_markdown_chunks class VoiceHandlerTests(unittest.IsolatedAsyncioTestCase): @@ -42,8 +45,12 @@ class VoiceHandlerTests(unittest.IsolatedAsyncioTestCase): await private_voice(update, context) + rendered = render_markdown_chunks( + "🎙️ **Голосовое сообщение слишком длинное.** Максимум: `120` сек." + )[0][0] update.message.reply_text.assert_awaited_once_with( - "Голосовое сообщение слишком длинное. Максимум: 120 сек." + rendered, + parse_mode=ParseMode.MARKDOWN_V2, ) recognizer.transcribe.assert_not_called() @@ -64,7 +71,13 @@ class VoiceHandlerTests(unittest.IsolatedAsyncioTestCase): telegram_file.download_to_drive.assert_awaited_once_with( custom_path=temporary_path ) - status.edit_text.assert_awaited_once_with("Распознано: Напомни позвонить") + rendered = render_markdown_chunks( + "🎙️ **Распознано:** Напомни позвонить" + )[0][0] + status.edit_text.assert_awaited_once_with( + rendered, + parse_mode=ParseMode.MARKDOWN_V2, + ) run_agent.assert_awaited_once_with( update, context,