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.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")
|
||||
|
||||
@@ -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