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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,38 +1,215 @@
from html import escape
import logging
import re
from uuid import uuid4
from zoneinfo import ZoneInfo
from telegram import InlineQueryResultArticle, InputTextMessageContent, Message, Update
from telegram import Bot, InlineQueryResultArticle, InputTextMessageContent, Message, Update
from telegram.constants import ParseMode
from telegram.error import BadRequest
from telegram.ext import ContextTypes
from telegramify_markdown import (
convert,
entities_to_markdownv2,
split_entities,
utf16_len,
)
from telegramify_markdown.config import RenderConfig
from .ai import AIClient
from .config import HTML_FORMATS, MAX_TELEGRAM_MESSAGE_LENGTH
from .config import MAX_TELEGRAM_MESSAGE_LENGTH
from .speech import SpeechTranscriber
from .storage import AssistantStorage
logger = logging.getLogger(__name__)
FORMATTING_FALLBACK_NOTICE = "⚠️ Не удалось применить форматирование."
_MARKDOWN_SPECIAL_CHARS = re.compile(r"([\\`*_[\]{}()#+\-.!|>~])")
_RENDER_CONFIG = RenderConfig()
_RENDER_CONFIG.markdown_symbol.heading_level_1 = ""
_RENDER_CONFIG.markdown_symbol.heading_level_2 = ""
_RENDER_CONFIG.markdown_symbol.heading_level_3 = ""
_RENDER_CONFIG.markdown_symbol.heading_level_4 = ""
def escape_markdown_text(value: object) -> str:
"""Escape dynamic text before embedding it into ordinary Markdown."""
return _MARKDOWN_SPECIAL_CHARS.sub(r"\\\1", str(value))
def markdown_code(value: object) -> str:
"""Render a dynamic value as a safe CommonMark inline-code span."""
text = str(value)
longest_run = max((len(run) for run in re.findall(r"`+", text)), default=0)
delimiter = "`" * (longest_run + 1)
padding = " " if text.startswith(("`", " ")) or text.endswith(("`", " ")) else ""
return f"{delimiter}{padding}{text}{padding}{delimiter}"
def render_markdown_chunks(markdown: str) -> list[tuple[str, str]]:
"""Return (MarkdownV2, plain text) pairs that fit Telegram's limit."""
plain_text, entities = convert(
markdown,
latex_escape=False,
config=_RENDER_CONFIG,
)
pending = list(
split_entities(
plain_text,
entities,
max_utf16_len=MAX_TELEGRAM_MESSAGE_LENGTH,
)
)
chunks: list[tuple[str, str]] = []
while pending:
chunk_text, chunk_entities = pending.pop(0)
markdown_v2 = entities_to_markdownv2(chunk_text, chunk_entities)
if utf16_len(markdown_v2) <= MAX_TELEGRAM_MESSAGE_LENGTH:
chunks.append((markdown_v2, chunk_text))
continue
plain_length = utf16_len(chunk_text)
if plain_length <= 1:
raise ValueError("Unable to split MarkdownV2 within Telegram's limit")
sub_limit = max(1, plain_length // 2)
sub_chunks = list(
split_entities(
chunk_text,
chunk_entities,
max_utf16_len=sub_limit,
)
)
if len(sub_chunks) == 1:
sub_chunks = list(
split_entities(
chunk_text,
chunk_entities,
max_utf16_len=max(1, plain_length - 1),
)
)
if len(sub_chunks) == 1:
raise ValueError("Unable to split MarkdownV2 within Telegram's limit")
pending = sub_chunks + pending
return chunks
def _plain_chunks(text: str) -> list[str]:
chunks = split_entities(
text,
[],
max_utf16_len=MAX_TELEGRAM_MESSAGE_LENGTH,
)
return [chunk_text for chunk_text, _entities in chunks] or [text]
def _prepare_chunks(markdown: str) -> tuple[list[tuple[str, str]], bool]:
try:
chunks = render_markdown_chunks(markdown)
except Exception:
logger.exception("Failed to convert Markdown to Telegram MarkdownV2")
return [(chunk, chunk) for chunk in _plain_chunks(markdown)], True
return chunks or [("", "")], False
def _with_fallback_notice(text: str) -> str:
return f"{text}\n\n{FORMATTING_FALLBACK_NOTICE}"
def _with_markdown_notice(markdown_v2: str) -> str:
notice = entities_to_markdownv2(FORMATTING_FALLBACK_NOTICE, [])
return f"{markdown_v2}\n\n{notice}"
async def reply_markdown(message: Message, markdown: str) -> Message:
chunks, formatting_failed = _prepare_chunks(markdown)
first_message: Message | None = None
for index, (markdown_v2, plain_text) in enumerate(chunks):
is_last = index == len(chunks) - 1
formatted_text = (
_with_markdown_notice(markdown_v2)
if is_last and formatting_failed
else markdown_v2
)
try:
sent = await message.reply_text(
formatted_text,
parse_mode=ParseMode.MARKDOWN_V2,
)
except BadRequest:
logger.warning("Telegram rejected MarkdownV2; retrying as plain text")
formatting_failed = True
fallback_text = (
_with_fallback_notice(plain_text) if is_last else plain_text
)
sent = await message.reply_text(fallback_text)
if first_message is None:
first_message = sent
assert first_message is not None
return first_message
async def edit_markdown(message: Message, markdown: str) -> None:
await edit_or_reply(message, markdown)
async def send_markdown(bot: Bot, chat_id: int, markdown: str) -> Message:
chunks, formatting_failed = _prepare_chunks(markdown)
first_message: Message | None = None
for index, (markdown_v2, plain_text) in enumerate(chunks):
is_last = index == len(chunks) - 1
formatted_text = (
_with_markdown_notice(markdown_v2)
if is_last and formatting_failed
else markdown_v2
)
try:
sent = await bot.send_message(
chat_id=chat_id,
text=formatted_text,
parse_mode=ParseMode.MARKDOWN_V2,
)
except BadRequest:
logger.warning("Telegram rejected MarkdownV2; retrying as plain text")
formatting_failed = True
fallback_text = (
_with_fallback_notice(plain_text) if is_last else plain_text
)
sent = await bot.send_message(chat_id=chat_id, text=fallback_text)
if first_message is None:
first_message = sent
assert first_message is not None
return first_message
def make_article(
title: str,
text: str,
parse_mode: str | None = None,
markdown: str,
) -> InlineQueryResultArticle:
markdown_v2 = render_markdown_chunks(markdown)[0][0]
return InlineQueryResultArticle(
id=f"inline_{uuid4()}",
title=title,
input_message_content=InputTextMessageContent(text, parse_mode=parse_mode),
input_message_content=InputTextMessageContent(
markdown_v2,
parse_mode=ParseMode.MARKDOWN_V2,
),
)
def build_inline_results(query: str) -> list[InlineQueryResultArticle]:
escaped_query = escape(query)
escaped_query = escape_markdown_text(query)
return [
make_article("Caps", query.upper()),
*[
make_article(title, f"<{tag}>{escaped_query}</{tag}>", ParseMode.HTML)
for title, tag in HTML_FORMATS
],
make_article("Caps", escape_markdown_text(query.upper())),
make_article("Bold", f"**{escaped_query}**"),
make_article("Italic", f"*{escaped_query}*"),
]
@@ -68,24 +245,41 @@ def require_user_id(update: Update) -> int | None:
async def reply_long(message: Message, text: str) -> None:
chunks = [
text[index : index + MAX_TELEGRAM_MESSAGE_LENGTH]
for index in range(0, len(text), MAX_TELEGRAM_MESSAGE_LENGTH)
] or [""]
for chunk in chunks:
await message.reply_text(chunk)
await reply_markdown(message, text)
async def edit_or_reply(message: Message, text: str) -> None:
chunks = [
text[index : index + MAX_TELEGRAM_MESSAGE_LENGTH]
for index in range(0, len(text), MAX_TELEGRAM_MESSAGE_LENGTH)
] or [""]
chunks, formatting_failed = _prepare_chunks(text)
await message.edit_text(chunks[0])
for chunk in chunks[1:]:
await message.reply_text(chunk)
for index, (markdown_v2, plain_text) in enumerate(chunks):
is_first = index == 0
is_last = index == len(chunks) - 1
formatted_text = (
_with_markdown_notice(markdown_v2)
if is_last and formatting_failed
else markdown_v2
)
try:
if is_first:
await message.edit_text(
formatted_text,
parse_mode=ParseMode.MARKDOWN_V2,
)
else:
await message.reply_text(
formatted_text,
parse_mode=ParseMode.MARKDOWN_V2,
)
except BadRequest:
logger.warning("Telegram rejected MarkdownV2; retrying as plain text")
formatting_failed = True
fallback_text = (
_with_fallback_notice(plain_text) if is_last else plain_text
)
if is_first:
await message.edit_text(fallback_text)
else:
await message.reply_text(fallback_text)
def parse_positive_int(value: str) -> int | None: