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,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: