187 lines
6.1 KiB
Python
187 lines
6.1 KiB
Python
"""Telegram bot for uploading .FIT files."""
|
|
|
|
import logging
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from aiogram import Bot, Dispatcher, Router, F
|
|
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, WebAppInfo
|
|
from aiogram.filters import CommandStart
|
|
from sqlalchemy import select
|
|
|
|
from backend.app.core.config import settings
|
|
from backend.app.core.database import async_session
|
|
from backend.app.models.rider import Rider
|
|
from backend.app.models.activity import Activity
|
|
from backend.app.models.fitness import PowerCurve
|
|
from backend.app.services.fit_parser import parse_fit_file
|
|
from backend.app.services.metrics import calculate_metrics
|
|
from backend.app.services.intervals import detect_intervals
|
|
from backend.app.services.power_curve import calculate_power_curve
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = Router()
|
|
|
|
|
|
async def get_or_create_rider(telegram_id: int, name: str, username: str | None) -> Rider:
|
|
"""Find rider by telegram_id or create a new one."""
|
|
async with async_session() as session:
|
|
query = select(Rider).where(Rider.telegram_id == telegram_id)
|
|
result = await session.execute(query)
|
|
rider = result.scalar_one_or_none()
|
|
|
|
if not rider:
|
|
rider = Rider(
|
|
telegram_id=telegram_id,
|
|
telegram_username=username,
|
|
name=name,
|
|
)
|
|
session.add(rider)
|
|
await session.commit()
|
|
await session.refresh(rider)
|
|
|
|
return rider
|
|
|
|
|
|
async def process_fit_upload(content: bytes, rider: Rider) -> Activity:
|
|
"""Parse FIT file and save activity with all related data."""
|
|
upload_dir = Path(settings.UPLOAD_DIR)
|
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
file_id = uuid.uuid4()
|
|
file_path = upload_dir / f"{file_id}.fit"
|
|
file_path.write_bytes(content)
|
|
|
|
async with async_session() as session:
|
|
# Re-attach rider to this session
|
|
rider = await session.get(Rider, rider.id)
|
|
|
|
activity, data_points = parse_fit_file(content, rider.id, str(file_path))
|
|
session.add(activity)
|
|
await session.flush()
|
|
|
|
for dp in data_points:
|
|
dp.activity_id = activity.id
|
|
session.add_all(data_points)
|
|
|
|
metrics = calculate_metrics(data_points, activity, ftp=rider.ftp)
|
|
if metrics:
|
|
session.add(metrics)
|
|
|
|
intervals = detect_intervals(data_points, ftp=rider.ftp)
|
|
for interval in intervals:
|
|
interval.activity_id = activity.id
|
|
session.add_all(intervals)
|
|
|
|
curve_data = calculate_power_curve(data_points)
|
|
if curve_data:
|
|
pc = PowerCurve(activity_id=activity.id, curve_data=curve_data)
|
|
session.add(pc)
|
|
|
|
await session.commit()
|
|
await session.refresh(activity)
|
|
return activity
|
|
|
|
|
|
def format_duration(seconds: int) -> str:
|
|
h = seconds // 3600
|
|
m = (seconds % 3600) // 60
|
|
return f"{h}h {m}m" if h > 0 else f"{m}m"
|
|
|
|
|
|
@router.message(CommandStart())
|
|
async def cmd_start(message: Message):
|
|
user = message.from_user
|
|
await get_or_create_rider(
|
|
telegram_id=user.id,
|
|
name=user.full_name,
|
|
username=user.username,
|
|
)
|
|
await message.answer(
|
|
f"Welcome to VeloBrain, {user.first_name}!\n\n"
|
|
"Send me a .FIT file from your bike computer and I'll analyze your ride.\n\n"
|
|
"I'll calculate:\n"
|
|
"- Power metrics (NP, TSS, IF)\n"
|
|
"- Power zones & HR zones\n"
|
|
"- Power curve\n"
|
|
"- Interval detection\n\n"
|
|
"Just drag & drop your .FIT file here!"
|
|
)
|
|
|
|
|
|
@router.message(F.document)
|
|
async def handle_document(message: Message, bot: Bot):
|
|
doc = message.document
|
|
if not doc.file_name or not doc.file_name.lower().endswith(".fit"):
|
|
await message.answer("Please send a .FIT file.")
|
|
return
|
|
|
|
user = message.from_user
|
|
rider = await get_or_create_rider(
|
|
telegram_id=user.id,
|
|
name=user.full_name,
|
|
username=user.username,
|
|
)
|
|
|
|
status_msg = await message.answer("Processing your .FIT file...")
|
|
|
|
try:
|
|
file = await bot.download(doc)
|
|
content = file.read()
|
|
|
|
activity = await process_fit_upload(content, rider)
|
|
|
|
m = activity.metrics
|
|
lines = [
|
|
f"*{activity.name or 'Ride'}*",
|
|
f"Duration: {format_duration(activity.duration)}",
|
|
]
|
|
|
|
if activity.distance:
|
|
lines.append(f"Distance: {activity.distance / 1000:.1f} km")
|
|
if activity.elevation_gain:
|
|
lines.append(f"Elevation: {activity.elevation_gain:.0f} m")
|
|
|
|
if m:
|
|
if m.avg_power:
|
|
lines.append(f"Avg Power: {m.avg_power:.0f} W")
|
|
if m.normalized_power:
|
|
lines.append(f"NP: {m.normalized_power:.0f} W")
|
|
if m.tss:
|
|
lines.append(f"TSS: {m.tss:.0f}")
|
|
if m.intensity_factor:
|
|
lines.append(f"IF: {m.intensity_factor:.2f}")
|
|
if m.avg_hr:
|
|
lines.append(f"Avg HR: {m.avg_hr} bpm")
|
|
if m.avg_cadence:
|
|
lines.append(f"Avg Cadence: {m.avg_cadence} rpm")
|
|
|
|
intervals_count = len(activity.intervals or [])
|
|
if intervals_count > 0:
|
|
work = [i for i in activity.intervals if i.interval_type == "work"]
|
|
lines.append(f"Intervals: {len(work)} work / {intervals_count - len(work)} rest")
|
|
|
|
lines.append(f"\nView details in the web app!")
|
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Открыть в WebApp", web_app=WebAppInfo(url=f"https://sport.luminic.space//activities/{activity.id}"))]])
|
|
await status_msg.edit_text("\n".join(lines), parse_mode="Markdown", reply_markup=keyboard)
|
|
|
|
except Exception as e:
|
|
logger.exception("Error processing FIT file")
|
|
await status_msg.edit_text(f"Error processing file: {str(e)}")
|
|
|
|
|
|
@router.message()
|
|
async def handle_other(message: Message):
|
|
await message.answer(
|
|
"Send me a .FIT file to analyze your ride!\n"
|
|
"Use /start to see what I can do."
|
|
)
|
|
|
|
|
|
def create_bot() -> tuple[Bot, Dispatcher]:
|
|
bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
|
|
dp = Dispatcher()
|
|
dp.include_router(router)
|
|
return bot, dp
|