This commit is contained in:
kandrusyak
2026-07-25 15:07:53 +03:00
commit 6667e0d253
40 changed files with 3258 additions and 0 deletions

498
assistant_bot/handlers.py Normal file
View File

@@ -0,0 +1,498 @@
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,
get_ai_client,
get_speech_recognizer,
get_storage,
get_tz,
get_voice_max_duration,
parse_positive_int,
reply_long,
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 update.message.reply_text(
"Я готов как персональный ассистент.\n"
"Пиши обычным текстом или отправляй голосовые сообщения: "
"'запомни, что...', 'напомни завтра в 10:00...', "
"'сохрани заметку...', 'покажи мои статусы'.\n"
"Я помню последние реплики диалога; команда /new очищает текущий контекст.\n"
"Я сам решу, когда нужно вызвать внутренний tool."
)
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."
)
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 update.message.reply_text("Не могу определить пользователя.")
return
get_storage(context).start_new_conversation(user_id, int(update.message.chat_id))
await update.message.reply_text("Начинаем новую тему. Предыдущая переписка сохранена в архиве.")
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 update.message.reply_text("Не могу определить пользователя.")
return
query = command_text(context)
if not query:
await update.message.reply_text("Укажи тему или ключевые слова: /history отпуск")
return
rows = get_storage(context).search_conversation_messages(
user_id,
int(update.message.chat_id),
query,
limit=10,
)
if not rows:
await update.message.reply_text("В сохраненной переписке ничего не найдено.")
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']}"
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 update.message.reply_text("Напиши текст после /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 update.message.reply_text(
f"Голосовое сообщение слишком длинное. Максимум: {max_duration} сек."
)
return
await context.bot.send_chat_action(
chat_id=int(update.message.chat_id),
action=ChatAction.TYPING,
)
status_message = await update.message.reply_text("Распознаю голосовое сообщение...")
temp_path: Path | None = None
try:
telegram_file = await context.bot.get_file(voice.file_id)
file_descriptor, raw_path = tempfile.mkstemp(suffix=".ogg")
os.close(file_descriptor)
temp_path = Path(raw_path)
await telegram_file.download_to_drive(custom_path=temp_path)
transcript = await asyncio.to_thread(
get_speech_recognizer(context).transcribe,
temp_path,
)
except (SpeechRecognitionError, TelegramError):
logger.exception("Failed to transcribe Telegram voice message")
await status_message.edit_text(
"Не получилось распознать голосовое сообщение. Попробуй еще раз позже."
)
return
except Exception:
logger.exception("Unexpected error while processing Telegram voice message")
await status_message.edit_text(
"Произошла внутренняя ошибка при обработке голосового сообщения."
)
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 status_message.edit_text("Не удалось расслышать речь в сообщении.")
return
preview = transcript if len(transcript) <= 500 else f"{transcript[:497]}..."
await status_message.edit_text(f"Распознано: {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 update.message.reply_text(
f"Не получилось получить список моделей {ai_client.display_name}.\n{exc}"
)
return
if not models:
await update.message.reply_text(
f"{ai_client.display_name} доступна, но моделей не найдено."
)
return
await update.message.reply_text(
f"Модели {ai_client.display_name}:\n"
+ "\n".join(f"- {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 update.message.reply_text("Не могу определить пользователя.")
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))}"
)
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}"
)
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 update.message.reply_text("Не могу определить пользователя.")
return
if not text:
await update.message.reply_text("Напиши факт после команды: /remember я предпочитаю короткие ответы")
return
memory_id = get_storage(context).add_memory(user_id, text)
await update.message.reply_text(f"Запомнил. ID памяти: {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 update.message.reply_text("Не могу определить пользователя.")
return
rows = get_storage(context).list_memories(user_id)
text = format_numbered_rows(
rows,
lambda row: f"#{row['id']} - {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 update.message.reply_text("Не могу определить пользователя.")
return
if memory_id is None:
await update.message.reply_text("Укажи ID: /forget_memory 3")
return
deleted = get_storage(context).delete_memory(user_id, memory_id)
await update.message.reply_text("Удалил." 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 update.message.reply_text("Не могу определить пользователя.")
return
if not text:
await update.message.reply_text("Напиши текст заметки: /note идея для проекта")
return
note_id = get_storage(context).add_note(user_id, text)
await update.message.reply_text(f"Заметка сохранена. ID: {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 update.message.reply_text("Не могу определить пользователя.")
return
rows = get_storage(context).list_notes(user_id)
text = format_numbered_rows(
rows,
lambda row: f"#{row['id']} - {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 update.message.reply_text("Не могу определить пользователя.")
return
if note_id is None:
await update.message.reply_text("Укажи ID: /forget 3")
return
deleted = get_storage(context).delete_note(user_id, note_id)
await update.message.reply_text("Удалил." 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 update.message.reply_text("Не могу определить пользователя.")
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 отправить отчет"
)
return
reminder_id = get_storage(context).add_reminder(
user_id=user_id,
chat_id=int(update.effective_chat.id),
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))}."
)
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 update.message.reply_text("Не могу определить пользователя.")
return
tz = get_tz(context)
rows = get_storage(context).list_reminders(user_id)
text = format_numbered_rows(
rows,
lambda row: f"#{row['id']} - {format_local_dt(row['remind_at'], tz)} - {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 update.message.reply_text("Не могу определить пользователя.")
return
if reminder_id is None:
await update.message.reply_text("Укажи ID: /cancelreminder 3")
return
cancelled = get_storage(context).cancel_reminder(user_id, reminder_id)
await update.message.reply_text("Отменил." 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 update.message.reply_text("Не могу определить пользователя.")
return
if not raw_text:
await update.message.reply_text("Формат: /track паспорт | жду ответа")
return
title, status = split_tracking_text(raw_text)
if not title:
await update.message.reply_text("Название не должно быть пустым.")
return
item_id = get_storage(context).add_tracked_item(user_id, title, status)
await update.message.reply_text(f"Добавил отслеживание #{item_id}: {title} - {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 update.message.reply_text("Не могу определить пользователя.")
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(
rows,
lambda row: f"#{row['id']} - {row['title']}: {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 update.message.reply_text("Формат: /status 3 новый статус")
return
updated = storage.set_tracked_status(user_id, item_id, new_status)
await update.message.reply_text("Статус обновлен." 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 update.message.reply_text("Не могу определить пользователя.")
return
if item_id is None:
await update.message.reply_text("Укажи ID: /untrack 3")
return
deleted = get_storage(context).delete_tracked_item(user_id, item_id)
await update.message.reply_text("Удалил." 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")