From 922b1b26a6539e86e26bf8cfad3bd97330dfc079 Mon Sep 17 00:00:00 2001 From: nekenny Date: Fri, 3 Apr 2026 22:46:20 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20Telegram=20bot=20=E2=80=94=20full=20fun?= =?UTF-8?q?ctionality=20with=20inline=20keyboards,=20file=20upload,=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- backend/app/main.py | 7 + backend/app/messengers/__init__.py | 0 backend/app/messengers/base.py | 24 ++ backend/app/messengers/telegram/__init__.py | 0 backend/app/messengers/telegram/bot.py | 59 ++++ backend/app/messengers/telegram/handlers.py | 276 +++++++++++++++++++ backend/app/messengers/telegram/keyboards.py | 39 +++ backend/tests/test_telegram.py | 34 +++ 8 files changed, 439 insertions(+) create mode 100644 backend/app/messengers/__init__.py create mode 100644 backend/app/messengers/base.py create mode 100644 backend/app/messengers/telegram/__init__.py create mode 100644 backend/app/messengers/telegram/bot.py create mode 100644 backend/app/messengers/telegram/handlers.py create mode 100644 backend/app/messengers/telegram/keyboards.py create mode 100644 backend/tests/test_telegram.py diff --git a/backend/app/main.py b/backend/app/main.py index 9176ee0..8a2ca47 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,9 @@ from app.api.protocols import router as protocols_router from app.api.recordings import router as recordings_router from app.api.settings import router as settings_router from app.api.templates import router as templates_router +from app.config import settings from app.database import async_session +from app.messengers.telegram.bot import api_router as telegram_router, telegram_service from app.models.prompt_template import PromptTemplate from app.prompts.defaults import DEFAULT_TEMPLATES @@ -27,7 +29,11 @@ async def seed_default_templates(): @asynccontextmanager async def lifespan(app: FastAPI): await seed_default_templates() + if settings.telegram_bot_token: + await telegram_service.setup() yield + if settings.telegram_bot_token: + await telegram_service.shutdown() app = FastAPI(title="Meeting Protocol Service", lifespan=lifespan) @@ -37,6 +43,7 @@ app.include_router(protocols_router) app.include_router(templates_router) app.include_router(exports_router) app.include_router(settings_router) +app.include_router(telegram_router) @app.get("/api/health") diff --git a/backend/app/messengers/__init__.py b/backend/app/messengers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/messengers/base.py b/backend/app/messengers/base.py new file mode 100644 index 0000000..f9bbddb --- /dev/null +++ b/backend/app/messengers/base.py @@ -0,0 +1,24 @@ +from abc import ABC, abstractmethod +from typing import Any + + +class MessengerService(ABC): + @abstractmethod + async def setup(self): + pass + + @abstractmethod + async def shutdown(self): + pass + + @abstractmethod + async def send_message(self, chat_id: str, text: str, **kwargs): + pass + + @abstractmethod + async def send_file(self, chat_id: str, file_path: str, caption: str = ""): + pass + + @abstractmethod + async def notify_progress(self, chat_id: str, status: str, progress: int | None = None): + pass diff --git a/backend/app/messengers/telegram/__init__.py b/backend/app/messengers/telegram/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/messengers/telegram/bot.py b/backend/app/messengers/telegram/bot.py new file mode 100644 index 0000000..3c548b1 --- /dev/null +++ b/backend/app/messengers/telegram/bot.py @@ -0,0 +1,59 @@ +from aiogram import Bot, Dispatcher +from aiogram.webhook.aiohttp_server import SimpleRequestHandler +from fastapi import APIRouter, Request, Response + +from app.config import settings +from app.messengers.base import MessengerService +from app.messengers.telegram.handlers import router as handlers_router + +api_router = APIRouter() + + +class TelegramService(MessengerService): + def __init__(self): + self.bot = Bot(token=settings.telegram_bot_token) + self.dp = Dispatcher() + self.dp.include_router(handlers_router) + + async def setup(self): + if settings.telegram_webhook_url: + await self.bot.set_webhook( + settings.telegram_webhook_url, + drop_pending_updates=True, + ) + + async def shutdown(self): + await self.bot.delete_webhook() + await self.bot.session.close() + + async def send_message(self, chat_id: str, text: str, **kwargs): + await self.bot.send_message(int(chat_id), text, **kwargs) + + async def send_file(self, chat_id: str, file_path: str, caption: str = ""): + from aiogram.types import FSInputFile + await self.bot.send_document( + int(chat_id), + FSInputFile(file_path), + caption=caption, + ) + + async def notify_progress(self, chat_id: str, status: str, progress: int | None = None): + text = f"⚙️ {status}" + if progress is not None: + bar = "▓" * (progress // 10) + "░" * (10 - progress // 10) + text += f"\n{bar} {progress}%" + await self.send_message(chat_id, text) + + async def feed_webhook(self, request: Request) -> Response: + update = await request.json() + from aiogram.types import Update + await self.dp.feed_update(self.bot, Update(**update)) + return Response(status_code=200) + + +telegram_service = TelegramService() + + +@api_router.post("/api/telegram/webhook") +async def telegram_webhook(request: Request): + return await telegram_service.feed_webhook(request) diff --git a/backend/app/messengers/telegram/handlers.py b/backend/app/messengers/telegram/handlers.py new file mode 100644 index 0000000..61a94c3 --- /dev/null +++ b/backend/app/messengers/telegram/handlers.py @@ -0,0 +1,276 @@ +import os +import uuid + +from aiogram import Bot, F, Router +from aiogram.filters import Command +from aiogram.types import CallbackQuery, Message +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import settings +from app.messengers.telegram.keyboards import ( + protocol_actions_keyboard, + recording_list_keyboard, + template_choice_keyboard, +) +from app.models.prompt_template import PromptTemplate +from app.models.protocol import Protocol, ProtocolStatus +from app.models.recording import Recording, RecordingSource, RecordingStatus +from app.models.user import User +from app.services.auth import get_user_by_username, verify_password +from app.services.recording import get_upload_path +from app.tasks.transcription import transcribe_recording + +from app.tasks.transcription import SyncSession + +router = Router() + +_user_sessions: dict[int, uuid.UUID] = {} +_pending_files: dict[int, str] = {} + + +@router.message(Command("start")) +async def cmd_start(message: Message): + await message.answer( + "Добро пожаловать в Meeting Protocol Bot!\n\n" + "Для авторизации отправьте логин и пароль в формате:\n" + "/login username password" + ) + + +@router.message(Command("login")) +async def cmd_login(message: Message): + parts = (message.text or "").split(maxsplit=2) + if len(parts) < 3: + await message.answer("Формат: /login username password") + return + + username, password = parts[1], parts[2] + with SyncSession() as db: + from sqlalchemy import select as sync_select + result = db.execute(sync_select(User).where(User.username == username)) + user = result.scalar_one_or_none() + + if not user or not verify_password(password, user.password_hash): + await message.answer("❌ Неверный логин или пароль") + return + + _user_sessions[message.from_user.id] = user.id + await message.answer(f"✅ Авторизация успешна! Привет, {user.username}.\n\nОтправьте аудио/видео файл для обработки.") + + +@router.message(Command("list")) +async def cmd_list(message: Message): + user_id = _user_sessions.get(message.from_user.id) + if not user_id: + await message.answer("Сначала авторизуйтесь: /login username password") + return + + with SyncSession() as db: + result = db.execute( + select(Recording) + .where(Recording.user_id == user_id) + .order_by(Recording.created_at.desc()) + .limit(10) + ) + recordings = result.scalars().all() + + if not recordings: + await message.answer("У вас пока нет записей.") + return + + recs = [{"id": str(r.id), "title": r.title, "status": r.status.value} for r in recordings] + await message.answer("📋 Ваши записи:", reply_markup=recording_list_keyboard(recs)) + + +@router.message(Command("protocols")) +async def cmd_protocols(message: Message): + user_id = _user_sessions.get(message.from_user.id) + if not user_id: + await message.answer("Сначала авторизуйтесь: /login username password") + return + + with SyncSession() as db: + result = db.execute( + select(Protocol) + .join(Protocol.transcription) + .join(Recording) + .where(Recording.user_id == user_id) + .order_by(Protocol.created_at.desc()) + .limit(10) + ) + protocols = result.scalars().all() + + if not protocols: + await message.answer("Протоколов пока нет.") + return + + text_parts = [] + for p in protocols: + status_emoji = {"generating": "⚙️", "done": "✅", "error": "❌"} + emoji = status_emoji.get(p.status.value, "") + text_parts.append(f"{emoji} `{str(p.id)[:8]}` — {p.llm_model or 'N/A'}") + + await message.answer( + "📋 Ваши протоколы:\n\n" + "\n".join(text_parts) + + "\n\nДля просмотра: /view ", + parse_mode="Markdown", + ) + + +@router.message(Command("templates")) +async def cmd_templates(message: Message): + with SyncSession() as db: + result = db.execute(select(PromptTemplate)) + templates = result.scalars().all() + + text_parts = [f"• {t.name} — {t.description}" for t in templates] + await message.answer("📐 Доступные шаблоны:\n\n" + "\n".join(text_parts)) + + +@router.message(Command("help")) +async def cmd_help(message: Message): + await message.answer( + "📖 Команды:\n\n" + "/start — приветствие\n" + "/login username password — авторизация\n" + "/list — список записей\n" + "/protocols — список протоколов\n" + "/templates — список шаблонов\n" + "/view — просмотр протокола\n" + "/help — эта справка\n" + "/logout — выход\n\n" + "Или просто отправьте аудио/видео файл!" + ) + + +@router.message(Command("logout")) +async def cmd_logout(message: Message): + _user_sessions.pop(message.from_user.id, None) + await message.answer("Вы вышли из системы.") + + +@router.message(F.document | F.audio | F.video | F.voice | F.video_note) +async def handle_file(message: Message, bot: Bot): + user_id = _user_sessions.get(message.from_user.id) + if not user_id: + await message.answer("Сначала авторизуйтесь: /login username password") + return + + if message.document: + file_obj = message.document + filename = file_obj.file_name or "document" + elif message.audio: + file_obj = message.audio + filename = file_obj.file_name or "audio.mp3" + elif message.video: + file_obj = message.video + filename = f"video_{message.video.file_unique_id}.mp4" + elif message.voice: + file_obj = message.voice + filename = f"voice_{message.voice.file_unique_id}.ogg" + elif message.video_note: + file_obj = message.video_note + filename = f"videonote_{message.video_note.file_unique_id}.mp4" + else: + return + + if file_obj.file_size and file_obj.file_size > 50 * 1024 * 1024: + await message.answer( + f"⚠️ Файл слишком большой для Telegram (лимит 50 МБ).\n" + f"Загрузите через веб-интерфейс: {settings.app_url}" + ) + return + + await message.answer("⏬ Загружаю файл...") + + file = await bot.get_file(file_obj.file_id) + file_path = get_upload_path(filename) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + await bot.download_file(file.file_path, file_path) + + file_size = os.path.getsize(file_path) + _pending_files[message.from_user.id] = file_path + + with SyncSession() as db: + result = db.execute(select(PromptTemplate)) + templates = result.scalars().all() + + tpls = [{"id": str(t.id), "name": t.name} for t in templates] + await message.answer( + "📁 Файл получен! Выберите тип протокола:", + reply_markup=template_choice_keyboard(tpls), + ) + + _pending_files[message.from_user.id] = { + "file_path": file_path, + "filename": filename, + "file_size": file_size, + } + + +@router.callback_query(F.data.startswith("template:")) +async def on_template_selected(callback: CallbackQuery): + user_id = _user_sessions.get(callback.from_user.id) + if not user_id: + await callback.answer("Сначала авторизуйтесь") + return + + file_info = _pending_files.pop(callback.from_user.id, None) + if not file_info or not isinstance(file_info, dict): + await callback.answer("Файл не найден, отправьте заново") + return + + template_id = callback.data.split(":")[1] + + from app.services.recording import get_format_from_filename + fmt = get_format_from_filename(file_info["filename"]) + + with SyncSession() as db: + recording = Recording( + user_id=user_id, + title=file_info["filename"], + file_path=file_info["file_path"], + file_size=file_info["file_size"], + format=fmt, + source=RecordingSource.telegram, + ) + db.add(recording) + db.commit() + db.refresh(recording) + recording_id = recording.id + + await callback.message.edit_text("⏳ Принято! Обработка началась...") + transcribe_recording.delay(str(recording_id)) + await callback.answer() + + +@router.callback_query(F.data.startswith("export:")) +async def on_export_requested(callback: CallbackQuery, bot: Bot): + parts = callback.data.split(":") + protocol_id, fmt = parts[1], parts[2] + + from app.services.export import export_docx, export_markdown, export_pdf + from app.models.export_file import ExportFormat + + with SyncSession() as db: + result = db.execute(select(Protocol).where(Protocol.id == uuid.UUID(protocol_id))) + protocol = result.scalar_one_or_none() + + if not protocol: + await callback.answer("Протокол не найден") + return + + exporters = {"docx": export_docx, "pdf": export_pdf, "md": export_markdown} + exporter = exporters.get(fmt) + if not exporter: + await callback.answer("Неизвестный формат") + return + + file_path = exporter(protocol.content, protocol.id) + from aiogram.types import FSInputFile + await bot.send_document( + callback.message.chat.id, + FSInputFile(file_path, filename=f"protocol.{fmt}"), + ) + await callback.answer() diff --git a/backend/app/messengers/telegram/keyboards.py b/backend/app/messengers/telegram/keyboards.py new file mode 100644 index 0000000..40d410a --- /dev/null +++ b/backend/app/messengers/telegram/keyboards.py @@ -0,0 +1,39 @@ +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + +def template_choice_keyboard(templates: list[dict]) -> InlineKeyboardMarkup: + buttons = [] + for tpl in templates: + buttons.append([InlineKeyboardButton( + text=tpl["name"], + callback_data=f"template:{tpl['id']}", + )]) + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def protocol_actions_keyboard(protocol_id: str, app_url: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📄 DOCX", callback_data=f"export:{protocol_id}:docx"), + InlineKeyboardButton(text="📕 PDF", callback_data=f"export:{protocol_id}:pdf"), + InlineKeyboardButton(text="📝 MD", callback_data=f"export:{protocol_id}:md"), + ], + [ + InlineKeyboardButton(text="🔄 Перегенерировать", callback_data=f"regen:{protocol_id}"), + ], + [ + InlineKeyboardButton(text="🌐 Открыть в вебе", url=f"{app_url}/protocols/{protocol_id}"), + ], + ]) + + +def recording_list_keyboard(recordings: list[dict]) -> InlineKeyboardMarkup: + buttons = [] + for rec in recordings[:10]: + status_emoji = {"uploaded": "⏳", "processing": "⚙️", "done": "✅", "error": "❌"} + emoji = status_emoji.get(rec["status"], "") + buttons.append([InlineKeyboardButton( + text=f"{emoji} {rec['title'][:40]}", + callback_data=f"rec:{rec['id']}", + )]) + return InlineKeyboardMarkup(inline_keyboard=buttons) diff --git a/backend/tests/test_telegram.py b/backend/tests/test_telegram.py new file mode 100644 index 0000000..a74ab99 --- /dev/null +++ b/backend/tests/test_telegram.py @@ -0,0 +1,34 @@ +import pytest +from app.messengers.telegram.keyboards import ( + template_choice_keyboard, + protocol_actions_keyboard, + recording_list_keyboard, +) + + +def test_template_choice_keyboard(): + templates = [ + {"id": "abc-123", "name": "Обследование"}, + {"id": "def-456", "name": "Внутренняя"}, + ] + kb = template_choice_keyboard(templates) + assert len(kb.inline_keyboard) == 2 + assert kb.inline_keyboard[0][0].text == "Обследование" + assert kb.inline_keyboard[0][0].callback_data == "template:abc-123" + + +def test_protocol_actions_keyboard(): + kb = protocol_actions_keyboard("proto-123", "http://localhost") + assert len(kb.inline_keyboard) == 3 + assert kb.inline_keyboard[0][0].callback_data == "export:proto-123:docx" + + +def test_recording_list_keyboard(): + recordings = [ + {"id": "rec-1", "title": "Meeting 1", "status": "done"}, + {"id": "rec-2", "title": "Meeting 2", "status": "processing"}, + ] + kb = recording_list_keyboard(recordings) + assert len(kb.inline_keyboard) == 2 + assert "✅" in kb.inline_keyboard[0][0].text + assert "⚙️" in kb.inline_keyboard[1][0].text