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:
2026-04-03 22:46:20 +05:00
parent 397b999558
commit 922b1b26a6
8 changed files with 439 additions and 0 deletions
+7
View File
@@ -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")
View File
+24
View File
@@ -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
+59
View File
@@ -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)
+276
View File
@@ -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)
+34
View File
@@ -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