614 lines
22 KiB
Python
614 lines
22 KiB
Python
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")
|