Files
kandrusyak_bot/assistant_bot/handlers.py

283 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import logging
import os
import tempfile
from pathlib import Path
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 .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,
reply_long,
reply_markdown,
require_user_id,
)
from .speech import SpeechRecognitionError
from .time_utils import format_local_dt
logger = logging.getLogger(__name__)
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 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")