499 lines
19 KiB
Python
499 lines
19 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,
|
||
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")
|