add formating

This commit is contained in:
kandrusyak
2026-07-27 17:14:07 +03:00
parent 7b7ff51d0f
commit 275eca8dc5
10 changed files with 628 additions and 157 deletions

View File

@@ -16,11 +16,15 @@ from .prompts import ASSISTANT_SYSTEM_PROMPT
from .reminders import parse_reminder from .reminders import parse_reminder
from .storage import AssistantStorage from .storage import AssistantStorage
from .telegram_utils import ( from .telegram_utils import (
edit_markdown,
edit_or_reply, edit_or_reply,
escape_markdown_text,
get_ai_client, get_ai_client,
get_storage, get_storage,
get_tz, get_tz,
markdown_code,
parse_positive_int, parse_positive_int,
reply_markdown,
require_user_id, require_user_id,
) )
from .time_utils import format_local_dt, to_utc_iso, utc_now 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. Пользователь пишет свободным текстом, " "Ты работаешь как агент с внутренними tools. Пользователь пишет свободным текстом, "
"а Telegram-команды ему не нужны.\n" "а Telegram-команды ему не нужны.\n"
f"Текущее локальное время: {now_local}. Таймзона: {tz.key}.\n\n" f"Текущее локальное время: {now_local}. Таймзона: {tz.key}.\n\n"
"Отвечай СТРОГО одним JSON-объектом без Markdown и без текста вокруг.\n" "Отвечай СТРОГО одним JSON-объектом без Markdown-блока и без текста вокруг.\n"
"Если нужно выполнить действие, верни tool_calls. Если действие уже выполнено " "Если нужно выполнить действие, верни tool_calls. Если действие уже выполнено "
"или tool не нужен, верни final.\n\n" "или tool не нужен, верни final.\n\n"
"Форматы ответа:\n" "Форматы ответа:\n"
'{"tool_calls":[{"name":"create_note","arguments":{"text":"..."}}],"reset_context":false}\n' '{"tool_calls":[{"name":"create_note","arguments":{"text":"..."}}],"reset_context":false}\n'
'{"final":"Короткий ответ пользователю","reset_context":false}\n\n' '{"final":"Короткий ответ пользователю","reset_context":false}\n\n'
"Значение final оформляй обычным Markdown, не MarkdownV2. Умеренно используй "
"жирный и курсивный текст, списки, ссылки и блоки кода, когда они улучшают "
"читаемость. Для короткого простого ответа разметка не обязательна. "
"Не добавляй декоративные эмодзи чаще одного раза на сообщение.\n\n"
"Доступные tools:\n" "Доступные tools:\n"
"- get_current_datetime {} - узнать текущее локальное и UTC-время.\n" "- get_current_datetime {} - узнать текущее локальное и UTC-время.\n"
'- remember {"text": string} - сохранить важный долгосрочный факт о пользователе.\n' '- remember {"text": string} - сохранить важный долгосрочный факт о пользователе.\n'
@@ -389,7 +397,10 @@ async def run_agent_prompt(
user_id = require_user_id(update) user_id = require_user_id(update)
if user_id is None: if user_id is None:
await update.effective_message.reply_text("Не могу определить пользователя.") await reply_markdown(
update.effective_message,
"⚠️ **Не могу определить пользователя.**",
)
return return
storage = get_storage(context) storage = get_storage(context)
@@ -428,8 +439,10 @@ async def run_agent_prompt(
chat_id=chat_id, chat_id=chat_id,
action=ChatAction.TYPING, action=ChatAction.TYPING,
) )
placeholder = await update.effective_message.reply_text( placeholder = await reply_markdown(
f"Думаю через {ai_client.display_name} / {model}..." update.effective_message,
f"💭 *Думаю через {escape_markdown_text(ai_client.display_name)} / "
f"{markdown_code(model)}…*",
) )
try: try:
@@ -502,7 +515,7 @@ async def run_agent_prompt(
"content": ( "content": (
"Лимит tool-шагов исчерпан. Больше не вызывай tools. " "Лимит tool-шагов исчерпан. Больше не вызывай tools. "
f"Последние результаты tools: {json_dumps(last_tool_results)}. " f"Последние результаты tools: {json_dumps(last_tool_results)}. "
"Сформулируй короткий финальный ответ пользователю обычным текстом." "Сформулируй короткий финальный ответ пользователю обычным Markdown."
), ),
}, },
], ],
@@ -523,13 +536,20 @@ async def run_agent_prompt(
hint = ( hint = (
"Проверь YANDEX_CLOUD_FOLDER, YC_API_KEY и доступ к выбранной модели." "Проверь YANDEX_CLOUD_FOLDER, YC_API_KEY и доступ к выбранной модели."
) )
await placeholder.edit_text( await edit_markdown(
f"Не получилось вызвать {ai_client.display_name}.\n{exc}\n\n{hint}" 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 return
except Exception: except Exception:
logger.exception("Failed to run AI prompt") logger.exception("Failed to run AI prompt")
await placeholder.edit_text("Произошла внутренняя ошибка при запросе к модели.") await edit_markdown(
placeholder,
"⚠️ **Произошла внутренняя ошибка** при запросе к модели.",
)
async def run_ai_prompt( async def run_ai_prompt(

View File

@@ -4,7 +4,7 @@ from telegram import Update
from telegram.constants import ChatType from telegram.constants import ChatType
from telegram.ext import ApplicationHandlerStop, ContextTypes 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: def passwords_match(candidate: str, expected: str) -> bool:
@@ -30,8 +30,9 @@ async def authentication_guard(
chat = update.effective_chat chat = update.effective_chat
if chat is not None and chat.type != ChatType.PRIVATE: if chat is not None and chat.type != ChatType.PRIVATE:
if message: if message:
await message.reply_text( await reply_markdown(
"Сначала авторизуйся в личном чате с ботом." message,
"🔐 **Сначала авторизуйся в личном чате с ботом.**",
) )
raise ApplicationHandlerStop raise ApplicationHandlerStop
@@ -39,18 +40,23 @@ async def authentication_guard(
password = str(context.application.bot_data["assistant_password"]) password = str(context.application.bot_data["assistant_password"])
if candidate and passwords_match(candidate, password): if candidate and passwords_match(candidate, password):
storage.authorize_user(user_id) storage.authorize_user(user_id)
await message.reply_text( await reply_markdown(
"Пароль принят. Доступ открыт — повторно вводить его не нужно." message,
"✅ **Пароль принят.** Доступ открыт — повторно вводить его не нужно.",
) )
raise ApplicationHandlerStop raise ApplicationHandlerStop
if candidate and not candidate.startswith("/"): if candidate and not candidate.startswith("/"):
await message.reply_text("Неверный пароль. Попробуй еще раз.") await reply_markdown(
message,
"🔐 **Неверный пароль.** Попробуй ещё раз.",
)
raise ApplicationHandlerStop raise ApplicationHandlerStop
if message: if message:
await message.reply_text( await reply_markdown(
"Для доступа к боту введи пароль одним текстовым сообщением." message,
"🔐 **Для доступа к боту введи пароль** одним текстовым сообщением.",
) )
elif update.inline_query: elif update.inline_query:
await update.inline_query.answer([], cache_time=0) await update.inline_query.answer([], cache_time=0)

View File

@@ -45,11 +45,6 @@ MAX_TELEGRAM_MESSAGE_LENGTH = 3900
MAX_AGENT_STEPS = 5 MAX_AGENT_STEPS = 5
CONVERSATION_HISTORY_LIMIT = 12 CONVERSATION_HISTORY_LIMIT = 12
HTML_FORMATS = (
("Bold", "b"),
("Italic", "i"),
)
def load_env_file(path: Path = ENV_FILE) -> None: def load_env_file(path: Path = ENV_FILE) -> None:
if not path.exists(): if not path.exists():

View File

@@ -16,13 +16,17 @@ from .reminders import parse_reminder
from .telegram_utils import ( from .telegram_utils import (
build_inline_results, build_inline_results,
command_text, command_text,
edit_markdown,
escape_markdown_text,
get_ai_client, get_ai_client,
get_speech_recognizer, get_speech_recognizer,
get_storage, get_storage,
get_tz, get_tz,
get_voice_max_duration, get_voice_max_duration,
markdown_code,
parse_positive_int, parse_positive_int,
reply_long, reply_long,
reply_markdown,
require_user_id, require_user_id,
) )
from .speech import SpeechRecognitionError from .speech import SpeechRecognitionError
@@ -44,30 +48,35 @@ def format_numbered_rows(
async def start(update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: async def start(update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None:
if update.message: if update.message:
await update.message.reply_text( await reply_markdown(
"Я готов как персональный ассистент.\n" update.message,
"Пиши обычным текстом или отправляй голосовые сообщения: " "**👋 Персональный ассистент готов**\n\n"
"'запомни, что...', 'напомни завтра в 10:00...', " "Пиши обычным текстом или отправляй голосовые сообщения. Например:\n\n"
"'сохрани заметку...', 'покажи мои статусы'.\n" "- Запомни, что я предпочитаю короткие ответы\n"
"Я помню последние реплики диалога; команда /new очищает текущий контекст.\n" "- Напомни завтра в 10:00 проверить почту\n"
"Я сам решу, когда нужно вызвать внутренний tool." "- Сохрани заметку с идеей проекта\n"
"- Покажи мои статусы\n\n"
"Я помню последние реплики диалога. Команда `/new` начинает новую тему, "
"не удаляя архив переписки.",
) )
async def help_command(update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: async def help_command(update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None:
if update.message: if update.message:
await update.message.reply_text( await reply_markdown(
"Основной режим - свободный диалог текстом или голосовыми сообщениями.\n\n" update.message,
"Примеры:\n" "**🧭 Как пользоваться ботом**\n\n"
"Запомни, что я предпочитаю короткие ответы.\n" "Основной режим — свободный диалог текстом или голосовыми сообщениями.\n\n"
"Сохрани заметку: идея для проекта.\n" "**Примеры запросов**\n\n"
"Напомни через 30 минут проверить сборку.\n" "- Запомни, что я предпочитаю короткие ответы\n"
"Отслеживай паспорт, статус: жду ответа.\n" "- Сохрани заметку: идея для проекта\n"
"Покажи активные напоминания.\n\n" "- Напомни через 30 минут проверить сборку\n"
"Вся переписка сохраняется. /history тема ищет старое обсуждение, " "- Отслеживай паспорт, статус: жду ответа\n"
"/new начинает новый контекст без удаления архива.\n\n" "- Покажи активные напоминания\n\n"
"Служебные команды оставлены для настройки и отладки: /models, /model, /ask.\n" "Вся переписка сохраняется. `/history тема` ищет старое обсуждение, "
"Режим выбирается через ASSISTANT_MODE=local или ASSISTANT_MODE=yandex." "а `/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 return
user_id = require_user_id(update) user_id = require_user_id(update)
if user_id is None: if user_id is None:
await update.message.reply_text("Не могу определить пользователя.") await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**")
return return
get_storage(context).start_new_conversation(user_id, int(update.message.chat_id)) 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: 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 return
user_id = require_user_id(update) user_id = require_user_id(update)
if user_id is None: if user_id is None:
await update.message.reply_text("Не могу определить пользователя.") await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**")
return return
query = command_text(context) query = command_text(context)
if not query: if not query:
await update.message.reply_text("Укажи тему или ключевые слова: /history отпуск") await reply_markdown(
update.message,
"🔎 Укажи тему или ключевые слова: `/history отпуск`",
)
return return
rows = get_storage(context).search_conversation_messages( rows = get_storage(context).search_conversation_messages(
@@ -102,14 +117,18 @@ async def history_command(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
limit=10, limit=10,
) )
if not rows: if not rows:
await update.message.reply_text("В сохраненной переписке ничего не найдено.") await reply_markdown(
update.message,
"🔎 **В сохранённой переписке ничего не найдено.**",
)
return return
role_names = {"user": "Вы", "assistant": "Бот"} role_names = {"user": "Вы", "assistant": "Бот"}
tz = get_tz(context) tz = get_tz(context)
text = "Найденные сообщения:\n\n" + "\n\n".join( text = "**🔎 Найденные сообщения**\n\n" + "\n\n".join(
f"{role_names.get(str(row['role']), row['role'])} · " f"**{escape_markdown_text(role_names.get(str(row['role']), row['role']))}** · "
f"{format_local_dt(row['created_at'], tz)}\n{row['content']}" 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 for row in rows
) )
await reply_long(update.message, text) 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) prompt = command_text(context)
if not prompt: if not prompt:
if update.message: if update.message:
await update.message.reply_text("Напиши текст после /ask или просто отправь сообщение в личный чат.") await reply_markdown(
update.message,
"💬 Напиши текст после `/ask` или просто отправь сообщение в личный чат.",
)
return return
await run_agent_prompt(update, context, prompt) 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 voice = update.message.voice
max_duration = get_voice_max_duration(context) max_duration = get_voice_max_duration(context)
if voice.duration > max_duration: if voice.duration > max_duration:
await update.message.reply_text( await reply_markdown(
f"Голосовое сообщение слишком длинное. Максимум: {max_duration} сек." update.message,
"🎙️ **Голосовое сообщение слишком длинное.** "
f"Максимум: {markdown_code(max_duration)} сек.",
) )
return return
@@ -146,7 +170,10 @@ async def private_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
chat_id=int(update.message.chat_id), chat_id=int(update.message.chat_id),
action=ChatAction.TYPING, action=ChatAction.TYPING,
) )
status_message = await update.message.reply_text("Распознаю голосовое сообщение...") status_message = await reply_markdown(
update.message,
"🎙️ *Распознаю голосовое сообщение…*",
)
temp_path: Path | None = None temp_path: Path | None = None
transcript = "" transcript = ""
@@ -163,14 +190,17 @@ async def private_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
) )
except (SpeechRecognitionError, TelegramError): except (SpeechRecognitionError, TelegramError):
logger.exception("Failed to transcribe Telegram voice message") logger.exception("Failed to transcribe Telegram voice message")
await status_message.edit_text( await edit_markdown(
"Не получилось распознать голосовое сообщение. Попробуй еще раз позже." status_message,
"⚠️ **Не получилось распознать голосовое сообщение.** "
"Попробуй ещё раз позже.",
) )
return return
except Exception: except Exception:
logger.exception("Unexpected error while processing Telegram voice message") logger.exception("Unexpected error while processing Telegram voice message")
await status_message.edit_text( await edit_markdown(
"Произошла внутренняя ошибка при обработке голосового сообщения." status_message,
"⚠️ **Произошла внутренняя ошибка** при обработке голосового сообщения.",
) )
return return
finally: 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) logger.warning("Could not delete temporary voice file %s", temp_path)
if not transcript: if not transcript:
await status_message.edit_text("Не удалось расслышать речь в сообщении.") await edit_markdown(
status_message,
"⚠️ **Не удалось расслышать речь в сообщении.**",
)
return return
preview = transcript if len(transcript) <= 500 else f"{transcript[:497]}..." 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) await run_agent_prompt(update, context, transcript)
@@ -197,20 +233,26 @@ async def models_command(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
try: try:
models = await ai_client.list_models() models = await ai_client.list_models()
except AIClientError as exc: except AIClientError as exc:
await update.message.reply_text( await reply_markdown(
f"Не получилось получить список моделей {ai_client.display_name}.\n{exc}" update.message,
"⚠️ **Не получилось получить список моделей "
f"{escape_markdown_text(ai_client.display_name)}.**\n\n"
f"{escape_markdown_text(exc)}",
) )
return return
if not models: if not models:
await update.message.reply_text( await reply_markdown(
f"{ai_client.display_name} доступна, но моделей не найдено." update.message,
f"🤖 **{escape_markdown_text(ai_client.display_name)} доступна, "
"но моделей не найдено.**",
) )
return return
await update.message.reply_text( await reply_markdown(
f"Модели {ai_client.display_name}:\n" update.message,
+ "\n".join(f"- {model}" for model in models) 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) user_id = require_user_id(update)
if user_id is None: if user_id is None:
await update.message.reply_text("Не могу определить пользователя.") await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**")
return return
storage = get_storage(context) storage = get_storage(context)
ai_client = get_ai_client(context) ai_client = get_ai_client(context)
model = command_text(context) model = command_text(context)
if not model: if not model:
await update.message.reply_text( await reply_markdown(
f"Текущая модель {ai_client.display_name}: " update.message,
f"{ai_client.normalize_model(storage.get_user_model(user_id, ai_client.provider))}" 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 return
model = ai_client.normalize_model(model) model = ai_client.normalize_model(model)
storage.set_user_model(user_id, model, ai_client.provider) storage.set_user_model(user_id, model, ai_client.provider)
await update.message.reply_text( await reply_markdown(
f"Модель {ai_client.display_name} сохранена: {model}" 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) user_id = require_user_id(update)
text = command_text(context) text = command_text(context)
if user_id is None: if user_id is None:
await update.message.reply_text("Не могу определить пользователя.") await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**")
return return
if not text: if not text:
await update.message.reply_text("Напиши факт после команды: /remember я предпочитаю короткие ответы") await reply_markdown(
update.message,
"🧠 Напиши факт после команды: "
"`/remember я предпочитаю короткие ответы`",
)
return return
memory_id = get_storage(context).add_memory(user_id, text) 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: 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) user_id = require_user_id(update)
if user_id is None: if user_id is None:
await update.message.reply_text("Не могу определить пользователя.") await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**")
return return
rows = get_storage(context).list_memories(user_id) rows = get_storage(context).list_memories(user_id)
text = format_numbered_rows( text = "**🧠 Долговременная память**\n\n" + format_numbered_rows(
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) 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) user_id = require_user_id(update)
memory_id = parse_positive_int(command_text(context)) memory_id = parse_positive_int(command_text(context))
if user_id is None: if user_id is None:
await update.message.reply_text("Не могу определить пользователя.") await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**")
return return
if memory_id is None: if memory_id is None:
await update.message.reply_text("Укажи ID: /forget_memory 3") await reply_markdown(
update.message,
"🧠 Укажи ID: `/forget_memory 3`",
)
return return
deleted = get_storage(context).delete_memory(user_id, memory_id) 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: 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) user_id = require_user_id(update)
text = command_text(context) text = command_text(context)
if user_id is None: if user_id is None:
await update.message.reply_text("Не могу определить пользователя.") await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**")
return return
if not text: if not text:
await update.message.reply_text("Напиши текст заметки: /note идея для проекта") await reply_markdown(
update.message,
"📝 Напиши текст заметки: `/note идея для проекта`",
)
return return
note_id = get_storage(context).add_note(user_id, text) 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: 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) user_id = require_user_id(update)
if user_id is None: if user_id is None:
await update.message.reply_text("Не могу определить пользователя.") await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**")
return return
rows = get_storage(context).list_notes(user_id) rows = get_storage(context).list_notes(user_id)
text = format_numbered_rows( text = "**📝 Заметки**\n\n" + format_numbered_rows(
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) 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) user_id = require_user_id(update)
note_id = parse_positive_int(command_text(context)) note_id = parse_positive_int(command_text(context))
if user_id is None: if user_id is None:
await update.message.reply_text("Не могу определить пользователя.") await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**")
return return
if note_id is None: if note_id is None:
await update.message.reply_text("Укажи ID: /forget 3") await reply_markdown(update.message, "📝 Укажи ID: `/forget 3`")
return return
deleted = get_storage(context).delete_note(user_id, note_id) 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: 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) user_id = require_user_id(update)
if user_id is None: if user_id is None:
await update.message.reply_text("Не могу определить пользователя.") await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**")
return return
parsed = parse_reminder(command_text(context), get_tz(context)) parsed = parse_reminder(command_text(context), get_tz(context))
if not parsed: if not parsed:
await update.message.reply_text( await reply_markdown(
"Формат напоминания:\n" update.message,
"/remind 30m позвонить\n" "**⏰ Формат напоминания**\n\n"
"/remind через 2 часа проверить статус\n" "- `/remind 30m позвонить`\n"
"/remind 18:30 отправить отчет\n" "- `/remind через 2 часа проверить статус`\n"
"/remind 2026-07-16 18:30 отправить отчет" "- `/remind 18:30 отправить отчёт`\n"
"- `/remind 2026-07-16 18:30 отправить отчёт`",
) )
return return
@@ -370,9 +446,10 @@ async def remind_command(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
text=parsed.text, text=parsed.text,
remind_at_utc=parsed.remind_at_utc, remind_at_utc=parsed.remind_at_utc,
) )
await update.message.reply_text( await reply_markdown(
f"Напоминание #{reminder_id} поставлено на " update.message,
f"{format_local_dt(parsed.remind_at_utc, get_tz(context))}." 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) user_id = require_user_id(update)
if user_id is None: if user_id is None:
await update.message.reply_text("Не могу определить пользователя.") await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**")
return return
tz = get_tz(context) tz = get_tz(context)
rows = get_storage(context).list_reminders(user_id) rows = get_storage(context).list_reminders(user_id)
text = format_numbered_rows( text = "**⏰ Активные напоминания**\n\n" + format_numbered_rows(
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) 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) user_id = require_user_id(update)
reminder_id = parse_positive_int(command_text(context)) reminder_id = parse_positive_int(command_text(context))
if user_id is None: if user_id is None:
await update.message.reply_text("Не могу определить пользователя.") await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**")
return return
if reminder_id is None: if reminder_id is None:
await update.message.reply_text("Укажи ID: /cancelreminder 3") await reply_markdown(
update.message,
"⏰ Укажи ID: `/cancelreminder 3`",
)
return return
cancelled = get_storage(context).cancel_reminder(user_id, reminder_id) 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]: 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) user_id = require_user_id(update)
raw_text = command_text(context) raw_text = command_text(context)
if user_id is None: if user_id is None:
await update.message.reply_text("Не могу определить пользователя.") await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**")
return return
if not raw_text: if not raw_text:
await update.message.reply_text("Формат: /track паспорт | жду ответа") await reply_markdown(
update.message,
"📌 Формат: `/track паспорт | жду ответа`",
)
return return
title, status = split_tracking_text(raw_text) title, status = split_tracking_text(raw_text)
if not title: if not title:
await update.message.reply_text("Название не должно быть пустым.") await reply_markdown(
update.message,
"⚠️ **Название не должно быть пустым.**",
)
return return
item_id = get_storage(context).add_tracked_item(user_id, title, status) 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: 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) user_id = require_user_id(update)
if user_id is None: if user_id is None:
await update.message.reply_text("Не могу определить пользователя.") await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**")
return return
raw_text = command_text(context) raw_text = command_text(context)
storage = get_storage(context) storage = get_storage(context)
if not raw_text: if not raw_text:
rows = storage.list_tracked_items(user_id) rows = storage.list_tracked_items(user_id)
text = format_numbered_rows( text = "**📌 Отслеживаемые статусы**\n\n" + format_numbered_rows(
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) await reply_long(update.message, text)
return return
@@ -466,11 +570,17 @@ async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
item_id = parse_positive_int(parts[0]) item_id = parse_positive_int(parts[0])
new_status = parts[1].strip() if len(parts) > 1 else "" new_status = parts[1].strip() if len(parts) > 1 else ""
if item_id is None or not new_status: if item_id is None or not new_status:
await update.message.reply_text("Формат: /status 3 новый статус") await reply_markdown(
update.message,
"📌 Формат: `/status 3 новый статус`",
)
return return
updated = storage.set_tracked_status(user_id, item_id, new_status) 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: 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) user_id = require_user_id(update)
item_id = parse_positive_int(command_text(context)) item_id = parse_positive_int(command_text(context))
if user_id is None: if user_id is None:
await update.message.reply_text("Не могу определить пользователя.") await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**")
return return
if item_id is None: if item_id is None:
await update.message.reply_text("Укажи ID: /untrack 3") await reply_markdown(update.message, "📌 Укажи ID: `/untrack 3`")
return return
deleted = get_storage(context).delete_tracked_item(user_id, item_id) 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: async def inline_query(update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None:

View File

@@ -7,6 +7,7 @@ from telegram.ext import Application
from .config import REMINDER_POLL_SECONDS from .config import REMINDER_POLL_SECONDS
from .storage import AssistantStorage from .storage import AssistantStorage
from .telegram_utils import escape_markdown_text, markdown_code, send_markdown
from .time_utils import format_local_dt, utc_now from .time_utils import format_local_dt, utc_now
@@ -21,12 +22,15 @@ async def reminder_loop(application: Application) -> None:
try: try:
due_reminders = storage.due_reminders(utc_now()) due_reminders = storage.due_reminders(utc_now())
for reminder in due_reminders: for reminder in due_reminders:
await application.bot.send_message( await send_markdown(
application.bot,
chat_id=reminder["chat_id"], chat_id=reminder["chat_id"],
text=( markdown=(
f"Напоминание #{reminder['id']} " f"**⏰ Напоминание "
f"({format_local_dt(reminder['remind_at'], tz)}):\n" f"{markdown_code('#' + str(reminder['id']))}**\n\n"
f"{reminder['text']}" 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"])) storage.mark_reminder_sent(int(reminder["id"]))

View File

@@ -1,38 +1,215 @@
from html import escape import logging
import re
from uuid import uuid4 from uuid import uuid4
from zoneinfo import ZoneInfo 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.constants import ParseMode
from telegram.error import BadRequest
from telegram.ext import ContextTypes 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 .ai import AIClient
from .config import HTML_FORMATS, MAX_TELEGRAM_MESSAGE_LENGTH from .config import MAX_TELEGRAM_MESSAGE_LENGTH
from .speech import SpeechTranscriber from .speech import SpeechTranscriber
from .storage import AssistantStorage 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( def make_article(
title: str, title: str,
text: str, markdown: str,
parse_mode: str | None = None,
) -> InlineQueryResultArticle: ) -> InlineQueryResultArticle:
markdown_v2 = render_markdown_chunks(markdown)[0][0]
return InlineQueryResultArticle( return InlineQueryResultArticle(
id=f"inline_{uuid4()}", id=f"inline_{uuid4()}",
title=title, 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]: def build_inline_results(query: str) -> list[InlineQueryResultArticle]:
escaped_query = escape(query) escaped_query = escape_markdown_text(query)
return [ return [
make_article("Caps", query.upper()), make_article("Caps", escape_markdown_text(query.upper())),
*[ make_article("Bold", f"**{escaped_query}**"),
make_article(title, f"<{tag}>{escaped_query}</{tag}>", ParseMode.HTML) make_article("Italic", f"*{escaped_query}*"),
for title, tag in HTML_FORMATS
],
] ]
@@ -68,24 +245,41 @@ def require_user_id(update: Update) -> int | None:
async def reply_long(message: Message, text: str) -> None: async def reply_long(message: Message, text: str) -> None:
chunks = [ await reply_markdown(message, text)
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)
async def edit_or_reply(message: Message, text: str) -> None: async def edit_or_reply(message: Message, text: str) -> None:
chunks = [ chunks, formatting_failed = _prepare_chunks(text)
text[index : index + MAX_TELEGRAM_MESSAGE_LENGTH]
for index in range(0, len(text), MAX_TELEGRAM_MESSAGE_LENGTH)
] or [""]
await message.edit_text(chunks[0]) for index, (markdown_v2, plain_text) in enumerate(chunks):
for chunk in chunks[1:]: is_first = index == 0
await message.reply_text(chunk) 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: def parse_positive_int(value: str) -> int | None:

View File

@@ -1 +1,2 @@
python-telegram-bot==22.8 python-telegram-bot==22.8
telegramify-markdown==1.2.0

View File

@@ -5,11 +5,12 @@ from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from telegram.constants import ChatType from telegram.constants import ChatType, ParseMode
from telegram.ext import ApplicationHandlerStop from telegram.ext import ApplicationHandlerStop
from assistant_bot.auth import authentication_guard from assistant_bot.auth import authentication_guard
from assistant_bot.storage import AssistantStorage from assistant_bot.storage import AssistantStorage
from assistant_bot.telegram_utils import render_markdown_chunks
class AuthenticationGuardTests(unittest.IsolatedAsyncioTestCase): class AuthenticationGuardTests(unittest.IsolatedAsyncioTestCase):
@@ -47,8 +48,12 @@ class AuthenticationGuardTests(unittest.IsolatedAsyncioTestCase):
await authentication_guard(update, context) await authentication_guard(update, context)
self.assertTrue(self.storage.is_user_authorized(42)) self.assertTrue(self.storage.is_user_authorized(42))
rendered = render_markdown_chunks(
"✅ **Пароль принят.** Доступ открыт — повторно вводить его не нужно."
)[0][0]
update.effective_message.reply_text.assert_awaited_once_with( 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: async def test_wrong_password_does_not_authorize_user(self) -> None:
@@ -58,8 +63,12 @@ class AuthenticationGuardTests(unittest.IsolatedAsyncioTestCase):
await authentication_guard(update, context) await authentication_guard(update, context)
self.assertFalse(self.storage.is_user_authorized(42)) self.assertFalse(self.storage.is_user_authorized(42))
rendered = render_markdown_chunks(
"🔐 **Неверный пароль.** Попробуй ещё раз."
)[0][0]
update.effective_message.reply_text.assert_awaited_once_with( update.effective_message.reply_text.assert_awaited_once_with(
"Неверный пароль. Попробуй еще раз." rendered,
parse_mode=ParseMode.MARKDOWN_V2,
) )
async def test_authorized_user_passes_guard(self) -> None: async def test_authorized_user_passes_guard(self) -> None:

View File

@@ -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()

View File

@@ -4,7 +4,10 @@ from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from telegram.constants import ParseMode
from assistant_bot.handlers import private_voice from assistant_bot.handlers import private_voice
from assistant_bot.telegram_utils import render_markdown_chunks
class VoiceHandlerTests(unittest.IsolatedAsyncioTestCase): class VoiceHandlerTests(unittest.IsolatedAsyncioTestCase):
@@ -42,8 +45,12 @@ class VoiceHandlerTests(unittest.IsolatedAsyncioTestCase):
await private_voice(update, context) await private_voice(update, context)
rendered = render_markdown_chunks(
"🎙️ **Голосовое сообщение слишком длинное.** Максимум: `120` сек."
)[0][0]
update.message.reply_text.assert_awaited_once_with( update.message.reply_text.assert_awaited_once_with(
"Голосовое сообщение слишком длинное. Максимум: 120 сек." rendered,
parse_mode=ParseMode.MARKDOWN_V2,
) )
recognizer.transcribe.assert_not_called() recognizer.transcribe.assert_not_called()
@@ -64,7 +71,13 @@ class VoiceHandlerTests(unittest.IsolatedAsyncioTestCase):
telegram_file.download_to_drive.assert_awaited_once_with( telegram_file.download_to_drive.assert_awaited_once_with(
custom_path=temporary_path 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( run_agent.assert_awaited_once_with(
update, update,
context, context,