282 lines
10 KiB
Python
282 lines
10 KiB
Python
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_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_markdown(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")
|