add formating
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"]))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
python-telegram-bot==22.8
|
python-telegram-bot==22.8
|
||||||
|
telegramify-markdown==1.2.0
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
116
tests/test_telegram_utils.py
Normal file
116
tests/test_telegram_utils.py
Normal 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()
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user