import asyncio import logging import os import tempfile from pathlib import Path from typing import Any from telegram import Update from telegram.constants import ChatAction from telegram.error import TelegramError from telegram.ext import ContextTypes from .ai import AIClientError from .agent import run_agent_prompt 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 from .time_utils import format_local_dt logger = logging.getLogger(__name__) def format_numbered_rows( rows: list[dict[str, Any]], formatter, empty_text: str, ) -> str: if not rows: return empty_text return "\n".join(formatter(row) for row in rows) async def start(update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: if update.message: 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 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`", ) async def new_conversation_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message: return user_id = require_user_id(update) if user_id is None: await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return get_storage(context).start_new_conversation(user_id, int(update.message.chat_id)) await reply_markdown( update.message, "🆕 **Начинаем новую тему.** Предыдущая переписка сохранена в архиве.", ) async def history_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message: return user_id = require_user_id(update) if user_id is None: await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return query = command_text(context) if not query: await reply_markdown( update.message, "🔎 Укажи тему или ключевые слова: `/history отпуск`", ) return rows = get_storage(context).search_conversation_messages( user_id, int(update.message.chat_id), query, limit=10, ) if not rows: await reply_markdown( update.message, "🔎 **В сохранённой переписке ничего не найдено.**", ) return role_names = {"user": "Вы", "assistant": "Бот"} tz = get_tz(context) 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) async def ask_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: prompt = command_text(context) if not prompt: if update.message: await reply_markdown( update.message, "💬 Напиши текст после `/ask` или просто отправь сообщение в личный чат.", ) return await run_agent_prompt(update, context, prompt) async def private_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message or not update.message.text: return await run_agent_prompt(update, context, update.message.text.strip()) async def private_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message or not update.message.voice: return voice = update.message.voice max_duration = get_voice_max_duration(context) if voice.duration > max_duration: await reply_markdown( update.message, "🎙️ **Голосовое сообщение слишком длинное.** " f"Максимум: {markdown_code(max_duration)} сек.", ) return await context.bot.send_chat_action( chat_id=int(update.message.chat_id), action=ChatAction.TYPING, ) status_message = await reply_markdown( update.message, "🎙️ *Распознаю голосовое сообщение…*", ) temp_path: Path | None = None transcript = "" try: telegram_file = await context.bot.get_file(voice.file_id) file_descriptor, raw_path = tempfile.mkstemp(suffix=".ogg") os.close(file_descriptor) voice_path = Path(raw_path) temp_path = voice_path await telegram_file.download_to_drive(custom_path=voice_path) transcript = await asyncio.to_thread( get_speech_recognizer(context).transcribe, voice_path, ) except (SpeechRecognitionError, TelegramError): logger.exception("Failed to transcribe Telegram voice message") await edit_markdown( status_message, "⚠️ **Не получилось распознать голосовое сообщение.** " "Попробуй ещё раз позже.", ) return except Exception: logger.exception("Unexpected error while processing Telegram voice message") await edit_markdown( status_message, "⚠️ **Произошла внутренняя ошибка** при обработке голосового сообщения.", ) return finally: if temp_path is not None: try: temp_path.unlink(missing_ok=True) except OSError: logger.warning("Could not delete temporary voice file %s", temp_path) if not transcript: await edit_markdown( status_message, "⚠️ **Не удалось расслышать речь в сообщении.**", ) return preview = transcript if len(transcript) <= 500 else f"{transcript[:497]}..." await edit_markdown( status_message, f"🎙️ **Распознано:** {escape_markdown_text(preview)}", ) await run_agent_prompt(update, context, transcript) async def models_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message: return ai_client = get_ai_client(context) try: models = await ai_client.list_models() except AIClientError as 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 reply_markdown( update.message, f"🤖 **{escape_markdown_text(ai_client.display_name)} доступна, " "но моделей не найдено.**", ) return 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), ) async def model_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message: return user_id = require_user_id(update) if user_id is None: await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return storage = get_storage(context) ai_client = get_ai_client(context) model = command_text(context) if not model: 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 reply_markdown( update.message, f"✅ **Модель {escape_markdown_text(ai_client.display_name)} сохранена:** " f"{markdown_code(model)}", ) async def remember_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message: return user_id = require_user_id(update) text = command_text(context) if user_id is None: await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return if not text: await reply_markdown( update.message, "🧠 Напиши факт после команды: " "`/remember я предпочитаю короткие ответы`", ) return memory_id = get_storage(context).add_memory(user_id, text) await reply_markdown( update.message, f"✅ **Запомнил.** ID памяти: {markdown_code(memory_id)}", ) async def memory_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message: return user_id = require_user_id(update) if user_id is None: await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return rows = get_storage(context).list_memories(user_id) text = "**🧠 Долговременная память**\n\n" + format_numbered_rows( rows, lambda row: ( f"- {markdown_code('#' + str(row['id']))} " f"{escape_markdown_text(row['text'])}" ), "*Пока пусто.*", ) await reply_long(update.message, text) async def forget_memory_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message: return user_id = require_user_id(update) memory_id = parse_positive_int(command_text(context)) if user_id is None: await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return if memory_id is None: await reply_markdown( update.message, "🧠 Укажи ID: `/forget_memory 3`", ) return deleted = get_storage(context).delete_memory(user_id, memory_id) await reply_markdown( update.message, "✅ **Запись памяти удалена.**" if deleted else "🔎 **Не нашёл такую запись памяти.**", ) async def note_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message: return user_id = require_user_id(update) text = command_text(context) if user_id is None: await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return if not text: await reply_markdown( update.message, "📝 Напиши текст заметки: `/note идея для проекта`", ) return note_id = get_storage(context).add_note(user_id, text) await reply_markdown( update.message, f"✅ **Заметка сохранена.** ID: {markdown_code(note_id)}", ) async def notes_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message: return user_id = require_user_id(update) if user_id is None: await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return rows = get_storage(context).list_notes(user_id) text = "**📝 Заметки**\n\n" + format_numbered_rows( rows, lambda row: ( f"- {markdown_code('#' + str(row['id']))} " f"{escape_markdown_text(row['text'])}" ), "*Пока пусто.*", ) await reply_long(update.message, text) async def forget_note_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message: return user_id = require_user_id(update) note_id = parse_positive_int(command_text(context)) if user_id is None: await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return if note_id is None: await reply_markdown(update.message, "📝 Укажи ID: `/forget 3`") return deleted = get_storage(context).delete_note(user_id, note_id) await reply_markdown( update.message, "✅ **Заметка удалена.**" if deleted else "🔎 **Не нашёл такую заметку.**", ) async def remind_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message or not update.effective_chat: return user_id = require_user_id(update) if user_id is None: await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return parsed = parse_reminder(command_text(context), get_tz(context)) if not parsed: await reply_markdown( update.message, "**⏰ Формат напоминания**\n\n" "- `/remind 30m позвонить`\n" "- `/remind через 2 часа проверить статус`\n" "- `/remind 18:30 отправить отчёт`\n" "- `/remind 2026-07-16 18:30 отправить отчёт`", ) return reminder_id = get_storage(context).add_reminder( user_id=user_id, chat_id=int(update.message.chat_id), text=parsed.text, remind_at_utc=parsed.remind_at_utc, ) await reply_markdown( update.message, f"✅ **Напоминание {markdown_code(f'#{reminder_id}')} поставлено на** " f"{markdown_code(format_local_dt(parsed.remind_at_utc, get_tz(context)))}.", ) async def reminders_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message: return user_id = require_user_id(update) if user_id is None: await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return tz = get_tz(context) rows = get_storage(context).list_reminders(user_id) text = "**⏰ Активные напоминания**\n\n" + format_numbered_rows( rows, 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) async def cancel_reminder_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message: return user_id = require_user_id(update) reminder_id = parse_positive_int(command_text(context)) if user_id is None: await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return if reminder_id is None: await reply_markdown( update.message, "⏰ Укажи ID: `/cancelreminder 3`", ) return cancelled = get_storage(context).cancel_reminder(user_id, reminder_id) await reply_markdown( update.message, "✅ **Напоминание отменено.**" if cancelled else "🔎 **Не нашёл активное напоминание.**", ) def split_tracking_text(raw: str) -> tuple[str, str]: if "|" not in raw: return raw.strip(), "open" title, status = raw.split("|", 1) return title.strip(), status.strip() or "open" async def track_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message: return user_id = require_user_id(update) raw_text = command_text(context) if user_id is None: await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return if not raw_text: await reply_markdown( update.message, "📌 Формат: `/track паспорт | жду ответа`", ) return title, status = split_tracking_text(raw_text) if not title: await reply_markdown( update.message, "⚠️ **Название не должно быть пустым.**", ) return item_id = get_storage(context).add_tracked_item(user_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: if not update.message: return user_id = require_user_id(update) if user_id is None: 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 = "**📌 Отслеживаемые статусы**\n\n" + format_numbered_rows( rows, 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 parts = raw_text.split(maxsplit=1) 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 reply_markdown( update.message, "📌 Формат: `/status 3 новый статус`", ) return updated = storage.set_tracked_status(user_id, item_id, new_status) await reply_markdown( update.message, "✅ **Статус обновлён.**" if updated else "🔎 **Не нашёл такой объект.**", ) async def untrack_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message: return user_id = require_user_id(update) item_id = parse_positive_int(command_text(context)) if user_id is None: await reply_markdown(update.message, "⚠️ **Не могу определить пользователя.**") return if item_id is None: await reply_markdown(update.message, "📌 Укажи ID: `/untrack 3`") return deleted = get_storage(context).delete_tracked_item(user_id, item_id) await reply_markdown( update.message, "✅ **Объект удалён.**" if deleted else "🔎 **Не нашёл такой объект.**", ) async def inline_query(update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: if not update.inline_query or not update.inline_query.query: return try: await update.inline_query.answer(build_inline_results(update.inline_query.query)) except Exception: logger.exception("Failed to answer inline query")