feat: Telegram bot — full functionality with inline keyboards, file upload, auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.recordings import router as recordings_router
|
||||||
from app.api.settings import router as settings_router
|
from app.api.settings import router as settings_router
|
||||||
from app.api.templates import router as templates_router
|
from app.api.templates import router as templates_router
|
||||||
|
from app.config import settings
|
||||||
from app.database import async_session
|
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.models.prompt_template import PromptTemplate
|
||||||
from app.prompts.defaults import DEFAULT_TEMPLATES
|
from app.prompts.defaults import DEFAULT_TEMPLATES
|
||||||
|
|
||||||
@@ -27,7 +29,11 @@ async def seed_default_templates():
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await seed_default_templates()
|
await seed_default_templates()
|
||||||
|
if settings.telegram_bot_token:
|
||||||
|
await telegram_service.setup()
|
||||||
yield
|
yield
|
||||||
|
if settings.telegram_bot_token:
|
||||||
|
await telegram_service.shutdown()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Meeting Protocol Service", lifespan=lifespan)
|
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(templates_router)
|
||||||
app.include_router(exports_router)
|
app.include_router(exports_router)
|
||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
|
app.include_router(telegram_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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 <id>",
|
||||||
|
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 <id> — просмотр протокола\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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user