#!/usr/bin/env python3
from __future__ import annotations
from functools import wraps
import asyncio
import html
import io
import logging
import sqlite3
import sys
import time
from datetime import date, datetime, time as dt_time, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Message, Update
from telegram.ext import (
Application,
CallbackQueryHandler,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
from telegram.error import RetryAfter, TelegramError
from mi_fitness.auth import XiaomiAuth
from miband_tracker import storage
from miband_tracker.config import ConfigError, Settings
from miband_tracker.secure_files import save_auth_token
from miband_tracker.sync import run_sync
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("telegram").setLevel(logging.WARNING)
logging.getLogger("telegram.ext").setLevel(logging.WARNING)
logger = logging.getLogger("fitness-bot")
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
SETTINGS = Settings.from_env()
BOT_TOKEN = SETTINGS.telegram_bot_token
ALLOWED_USER_ID = SETTINGS.telegram_allowed_user_id
DB_PATH = str(SETTINGS.db_path)
LOCAL_TZ = ZoneInfo("Europe/Moscow")
SYNC_LOCK = asyncio.Lock()
AUTH_LOCK = asyncio.Lock()
STEP_GOAL = 10_000 # можно вынести в env при желании
# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
def is_allowed(update: Update) -> bool:
uid = update.effective_user.id if update.effective_user else None
return uid == ALLOWED_USER_ID
def with_user_context(func):
@wraps(func)
async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs):
return await func(update, context, *args, **kwargs)
return wrapper
def get_user_db_path() -> str:
if ALLOWED_USER_ID is None:
return DB_PATH
return str(SETTINGS.user_db_path(ALLOWED_USER_ID))
def get_user_status_path() -> str:
if ALLOWED_USER_ID is None:
return str(SETTINGS.status_path)
return str(SETTINGS.user_status_path(ALLOWED_USER_ID))
def get_xiaomi_token_path() -> Path | None:
if ALLOWED_USER_ID is None:
return None
try:
return SETTINGS.token_path(ALLOWED_USER_ID)
except ConfigError:
return None
def has_xiaomi_token() -> bool:
token_path = get_xiaomi_token_path()
return bool(token_path and token_path.exists())
def esc(value: object) -> str:
return html.escape(str(value), quote=False)
# ---------------------------------------------------------------------------
# Formatters
# ---------------------------------------------------------------------------
def format_epoch(epoch: int | float | None, with_date: bool = True) -> str:
if not epoch:
return "н/д"
fmt = "%Y-%m-%d %H:%M" if with_date else "%H:%M"
return datetime.fromtimestamp(int(epoch), LOCAL_TZ).strftime(fmt)
def format_relative_time(epoch: int | float | None) -> str:
if not epoch:
return "н/д"
diff = int(time.time() - int(epoch))
if diff < 60:
return "только что"
diff_min = diff // 60
if diff_min < 60:
return f"{diff_min} мин. назад"
diff_hours = diff_min // 60
if diff_hours < 24:
return f"{diff_hours} ч. назад"
diff_days = diff_hours // 24
if diff_days == 1:
return "вчера"
return f"{diff_days} дн. назад"
def format_minutes(minutes: int | float | None) -> str:
if minutes is None:
return "н/д"
minutes = int(minutes)
return f"{minutes // 60} ч {minutes % 60:02d} мин"
def step_goal_bar(steps: int | float | None, goal: int = STEP_GOAL) -> str:
if steps is None:
steps = 0
steps_int = int(steps)
percent = min(100, round(steps_int / goal * 100)) if goal > 0 else 0
filled = percent // 10
bar = "█" * filled + "░" * (10 - filled)
return f"[{bar}] {percent}%"
def step_goal_text(steps: int | float | None, goal: int = STEP_GOAL) -> str:
if steps is None:
return f"Цель {goal:,} шагов".replace(",", " ")
steps_int = int(steps)
left = max(0, goal - steps_int)
if left:
text = f"Цель {goal:,} · осталось {left:,} шагов"
else:
text = f"Цель {goal:,} · дневная цель выполнена! 🎉"
return text.replace(",", " ")
def make_sparkline(values: list[float | int]) -> str:
if not values:
return ""
sparks = [" ", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
min_val = min(values)
max_val = max(values)
val_range = max_val - min_val
if val_range == 0:
return sparks[4] * min(len(values), 20)
sparkline = []
for val in values:
idx = int((val - min_val) / val_range * (len(sparks) - 1))
sparkline.append(sparks[idx])
return "".join(sparkline)
def get_sleep_sparkline(table: str, field: str, start_epoch: int, end_epoch: int) -> str:
rows = fetch_all(
f"SELECT {field} FROM {table} WHERE timestamp >= ? AND timestamp < ? ORDER BY timestamp ASC",
(start_epoch, end_epoch),
)
if not rows:
return ""
values = [row[field] for row in rows]
if len(values) > 20:
step = len(values) / 20
values = [values[int(i * step)] for i in range(20)]
return make_sparkline(values)
def relative_day_label(day_str: str | None) -> str:
if not day_str:
return "Последний день"
try:
day_value = parse_day(day_str)
except ValueError:
return f"День: {day_str}"
today = datetime.now(LOCAL_TZ).date()
if day_value == today:
return "Сегодня"
if day_value == today - timedelta(days=1):
return "Вчера"
return day_str
def day_bounds(day_value: date) -> tuple[int, int]:
start = datetime.combine(day_value, dt_time.min, tzinfo=LOCAL_TZ)
end = start + timedelta(days=1)
return int(start.timestamp()), int(end.timestamp())
def parse_day(day_str: str) -> date:
return datetime.strptime(day_str, "%Y-%m-%d").date()
# ---------------------------------------------------------------------------
# Sleep quality label
# ---------------------------------------------------------------------------
def sleep_total(sleep: sqlite3.Row) -> int:
"""Реальное время сна: total_duration_min если есть, иначе light+deep."""
total = int(sleep["total_duration_min"]) if sleep["total_duration_min"] else 0
if total > 0:
return total
return int(sleep["light_sleep_min"] or 0) + int(sleep["deep_sleep_min"] or 0)
def sleep_quality_label(total_min: int, deep_min: int) -> str:
"""Оценка качества сна по общей длительности и доле глубокого сна."""
if total_min >= 420 and deep_min >= 60:
return "🟢 Отличный"
if total_min >= 360 and deep_min >= 40:
return "🟡 Хороший"
if total_min >= 300:
return "🟠 Средний"
return "🔴 Недостаточный"
# ---------------------------------------------------------------------------
# Daily tip engine
# ---------------------------------------------------------------------------
def daily_tip(steps: sqlite3.Row | None, sleep: sqlite3.Row | None, hr: sqlite3.Row | None) -> str:
"""Генерирует один персональный инсайт на основе последних данных."""
tips = []
if steps:
s = int(steps["total_steps"] or 0)
if s < 5000:
tips.append("💡 Сегодня совсем мало шагов. Прогулка в 20 минут добавит ~2 000 шагов и заметно поднимет настроение.")
elif s >= STEP_GOAL:
tips.append("💡 Дневная норма шагов выполнена — отличный результат!")
elif s >= 7000:
tips.append(f"💡 До цели {STEP_GOAL:,} шагов осталось {STEP_GOAL - s:,} — почти дошли!".replace(",", " "))
if sleep:
total = sleep_total(sleep)
deep = int(sleep["deep_sleep_min"] or 0)
if total < 300:
tips.append("💡 Ночной сон меньше 5 часов — это мало. Постарайтесь лечь пораньше сегодня.")
elif total < 360:
tips.append("💡 Ночной сон меньше 6 часов. Постарайтесь лечь пораньше сегодня.")
elif deep < 30:
tips.append("💡 Глубокого сна было совсем мало. Попробуйте проветрить комнату и ограничить экраны за час до сна.")
if hr:
bpm = int(hr["value"])
if bpm > 90:
tips.append("💡 Пульс в покое выше нормы — возможно, организм устал или есть стресс. Следите за самочувствием.")
elif bpm < 50:
tips.append("💡 Очень низкий пульс — если это спортивная норма, всё хорошо. Если нет — стоит обратить внимание.")
if not tips:
tips.append("💡 Данных достаточно, продолжайте в том же духе!")
return tips[0] # показываем один самый актуальный совет
# ---------------------------------------------------------------------------
# DB: health
# ---------------------------------------------------------------------------
def health_db_exists() -> bool:
return storage.health_db_exists(SETTINGS, ALLOWED_USER_ID)
def health_conn() -> sqlite3.Connection:
conn = sqlite3.connect(get_user_db_path())
conn.execute("PRAGMA busy_timeout = 5000")
conn.row_factory = sqlite3.Row
return conn
def fetch_one(query: str, params: tuple = ()) -> sqlite3.Row | None:
return storage.fetch_one(SETTINGS, query, params, ALLOWED_USER_ID)
def fetch_all(query: str, params: tuple = ()) -> list[sqlite3.Row]:
return storage.fetch_all(SETTINGS, query, params, ALLOWED_USER_ID)
# ---------------------------------------------------------------------------
# DB: bot state
# ---------------------------------------------------------------------------
def init_state_db() -> None:
storage.init_state_db(SETTINGS)
def get_user_menu_msg_id(user_id: int) -> int | None:
try:
return storage.get_user_menu_msg_id(SETTINGS, user_id)
except Exception as e:
logger.warning("Failed to read menu message id for %s: %s", user_id, e)
return None
def set_user_menu_msg_id(user_id: int, msg_id: int) -> None:
try:
storage.set_user_menu_msg_id(SETTINGS, user_id, msg_id)
except Exception as e:
logger.warning("Failed to save menu message id for %s: %s", user_id, e)
# ---------------------------------------------------------------------------
# Telegram: safe edit with exponential backoff
# ---------------------------------------------------------------------------
async def safe_delete(message: Message | None) -> None:
if not message:
return
try:
await message.delete()
except Exception as e:
logger.debug("Failed to delete message: %s", e)
async def send_or_update_menu(
bot,
user_id: int,
text: str,
reply_markup: InlineKeyboardMarkup | None = None,
force_new: bool = False,
) -> None:
chat_id = user_id
msg_id = get_user_menu_msg_id(user_id)
if msg_id and not force_new:
for attempt in range(3):
try:
await bot.edit_message_text(
chat_id=chat_id,
message_id=msg_id,
text=text,
parse_mode="HTML",
reply_markup=reply_markup,
disable_web_page_preview=True,
)
return
except RetryAfter as e:
wait = e.retry_after + attempt
logger.warning("Flood control: waiting %ss (attempt %d)", wait, attempt + 1)
await asyncio.sleep(wait)
except TelegramError as e:
msg = str(e)
if "Message is not modified" in msg:
return
if "Message to edit not found" in msg or "message can't be edited" in msg.lower():
break # сообщение пропало — создаём новое
logger.warning("Edit failed (attempt %d): %s", attempt + 1, e)
break
# Старое сообщение устарело или не найдено — удаляем и создаём новое
if msg_id:
try:
await bot.delete_message(chat_id=chat_id, message_id=msg_id)
except Exception as e:
logger.debug("Failed to delete old menu message %s: %s", msg_id, e)
new_msg = await bot.send_message(
chat_id=chat_id,
text=text,
parse_mode="HTML",
reply_markup=reply_markup,
disable_web_page_preview=True,
)
set_user_menu_msg_id(user_id, new_msg.message_id)
async def update_menu(
update: Update,
context: ContextTypes.DEFAULT_TYPE,
text: str,
reply_markup: InlineKeyboardMarkup | None = None,
force_new: bool = False,
) -> None:
if not update.effective_user:
return
user_id = update.effective_user.id
if update.callback_query and update.callback_query.message:
set_user_menu_msg_id(user_id, update.callback_query.message.message_id)
await send_or_update_menu(context.bot, user_id, text, reply_markup, force_new)
# ---------------------------------------------------------------------------
# Data queries
# ---------------------------------------------------------------------------
def read_status_file() -> dict:
return storage.read_status_file(SETTINGS, ALLOWED_USER_ID)
def latest_steps() -> sqlite3.Row | None:
return fetch_one(
"""
SELECT date, total_steps, calories, distance_m, last_sync
FROM steps_daily
ORDER BY date DESC
LIMIT 1
"""
)
def latest_sleep() -> sqlite3.Row | None:
return fetch_one(
"""
SELECT date, light_sleep_min, deep_sleep_min, start_time, end_time,
COALESCE(rem_sleep_min, 0) AS rem_sleep_min,
COALESCE(awake_min, 0) AS awake_min,
COALESCE(total_duration_min, 0) AS total_duration_min,
COALESCE(sleep_score, 0) AS sleep_score
FROM sleep_daily
ORDER BY date DESC
LIMIT 1
"""
)
def latest_hr() -> sqlite3.Row | None:
return fetch_one("SELECT timestamp, value FROM heart_rate ORDER BY timestamp DESC LIMIT 1")
def latest_spo2() -> sqlite3.Row | None:
return fetch_one(
"SELECT timestamp, spo2, type FROM blood_oxygen ORDER BY timestamp DESC LIMIT 1"
)
def resting_hr(start_epoch: int, end_epoch: int) -> int | None:
"""Пульс покоя = минимальный за окно сна (игнорируем нули и аномалии < 30)."""
row = fetch_one(
"""
SELECT MIN(value) AS min_hr
FROM heart_rate
WHERE timestamp >= ? AND timestamp < ? AND value > 30
""",
(start_epoch, end_epoch),
)
if row and row["min_hr"]:
return int(row["min_hr"])
return None
def metric_stats(table: str, field: str, start_epoch: int, end_epoch: int) -> sqlite3.Row | None:
return fetch_one(
f"""
SELECT COUNT(*) AS count,
ROUND(AVG({field}), 1) AS avg_value,
MIN({field}) AS min_value,
MAX({field}) AS max_value
FROM {table}
WHERE timestamp >= ? AND timestamp < ?
""",
(start_epoch, end_epoch),
)
def sleep_window_stats(sleep: sqlite3.Row | None) -> tuple[sqlite3.Row | None, sqlite3.Row | None]:
if not sleep or not sleep["start_time"] or not sleep["end_time"]:
return None, None
hr = metric_stats("heart_rate", "value", int(sleep["start_time"]), int(sleep["end_time"]))
spo2 = metric_stats(
"blood_oxygen", "spo2", int(sleep["start_time"]), int(sleep["end_time"])
)
return hr, spo2
def day_summary(day_str: str) -> dict:
day_value = parse_day(day_str)
start_epoch, end_epoch = day_bounds(day_value)
steps = fetch_one(
"""
SELECT date, total_steps, calories, distance_m, last_sync
FROM steps_daily
WHERE date = ?
""",
(day_str,),
)
sleep = fetch_one(
"""
SELECT date, light_sleep_min, deep_sleep_min, start_time, end_time,
COALESCE(rem_sleep_min, 0) AS rem_sleep_min,
COALESCE(awake_min, 0) AS awake_min,
COALESCE(total_duration_min, 0) AS total_duration_min,
COALESCE(sleep_score, 0) AS sleep_score
FROM sleep_daily
WHERE date = ?
""",
(day_str,),
)
hr = metric_stats("heart_rate", "value", start_epoch, end_epoch)
spo2 = metric_stats("blood_oxygen", "spo2", start_epoch, end_epoch)
return {"date": day_str, "steps": steps, "sleep": sleep, "hr": hr, "spo2": spo2}
def available_days(limit: int = 14) -> list[str]:
rows = fetch_all(
"""
SELECT date FROM (
SELECT date FROM steps_daily
UNION
SELECT date FROM sleep_daily
)
ORDER BY date DESC
LIMIT ?
""",
(limit,),
)
return [row["date"] for row in rows]
def period_bounds(days: int) -> tuple[date, date, int, int]:
end_day = datetime.now(LOCAL_TZ).date()
start_day = end_day - timedelta(days=days - 1)
start_epoch, _ = day_bounds(start_day)
_, end_epoch = day_bounds(end_day)
return start_day, end_day, start_epoch, end_epoch
def period_summary(days: int) -> dict:
start_day, end_day, start_epoch, end_epoch = period_bounds(days)
steps = fetch_all(
"""
SELECT date, total_steps, calories, distance_m
FROM steps_daily
WHERE date BETWEEN ? AND ?
ORDER BY date DESC
""",
(start_day.isoformat(), end_day.isoformat()),
)
sleep = fetch_all(
"""
SELECT date, light_sleep_min, deep_sleep_min, start_time, end_time,
COALESCE(rem_sleep_min, 0) AS rem_sleep_min,
COALESCE(awake_min, 0) AS awake_min,
COALESCE(total_duration_min, 0) AS total_duration_min,
COALESCE(sleep_score, 0) AS sleep_score
FROM sleep_daily
WHERE date BETWEEN ? AND ?
ORDER BY date DESC
""",
(start_day.isoformat(), end_day.isoformat()),
)
hr = metric_stats("heart_rate", "value", start_epoch, end_epoch)
spo2 = metric_stats("blood_oxygen", "spo2", start_epoch, end_epoch)
return {
"days": days,
"start": start_day,
"end": end_day,
"steps": steps,
"sleep": sleep,
"hr": hr,
"spo2": spo2,
}
# ---------------------------------------------------------------------------
# Day emoji helpers — для календаря
# ---------------------------------------------------------------------------
def day_emoji(steps_row: sqlite3.Row | None, sleep_row: sqlite3.Row | None) -> str:
"""Один emoji для строки дня в календаре."""
score = 0
if steps_row and int(steps_row["total_steps"] or 0) >= STEP_GOAL:
score += 1
if sleep_row:
total = sleep_total(sleep_row)
if total >= 420:
score += 1
if score == 2:
return "🟢"
if score == 1:
return "🟡"
return "🔴"
# ---------------------------------------------------------------------------
# Dashboard text — «умный» с динамическим заголовком и советом дня
# ---------------------------------------------------------------------------
def main_menu_text() -> str:
steps = latest_steps()
sleep = latest_sleep()
hr = latest_hr()
spo2 = latest_spo2()
lines = []
# Шаги
if steps:
label = relative_day_label(steps["date"])
steps_count = int(steps["total_steps"])
lines.append(f"🚶 {esc(label)}: {steps_count:,} шагов".replace(",", " "))
lines.append(f" {step_goal_bar(steps_count)}")
lines.append(f" {step_goal_text(steps_count)}")
lines.append(
f" {float(steps['distance_m']) / 1000:.1f} км · {float(steps['calories']):.0f} ккал"
)
else:
lines.append("🚶 Шаги: жду первую синхронизацию")
lines.append("")
# Сон — краткая сводка на дашборде
if sleep:
total_sleep = sleep_total(sleep)
deep = int(sleep["deep_sleep_min"] or 0)
quality = sleep_quality_label(total_sleep, deep)
lines.append(f"😴 Сон: {format_minutes(total_sleep)} · {quality}")
lines.append(
f" {esc(relative_day_label(sleep['date']))} · "
f"глубокий: {deep} мин · лёгкий: {sleep['light_sleep_min']} мин"
+ (f" · REM: {sleep['rem_sleep_min']} мин" if sleep['rem_sleep_min'] else "")
)
else:
lines.append("😴 Сон: данных пока нет")
lines.append("")
# Пульс
if hr:
bpm = int(hr["value"])
lines.append(f"❤️ Пульс: {bpm} bpm · {format_relative_time(hr['timestamp'])}")
else:
lines.append("❤️ Пульс: данных пока нет")
# SpO2
if spo2:
spo2_val = float(spo2["spo2"])
color = "🟢" if spo2_val >= 95 else ("🟡" if spo2_val >= 90 else "🔴")
lines.append(f"🩸 SpO2: {color} {spo2_val:.1f}% · {format_relative_time(spo2['timestamp'])}")
else:
lines.append("🩸 SpO2: данных пока нет")
# Совет дня
tip = daily_tip(steps, sleep, hr)
lines.extend(["", tip])
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Day detail text
# ---------------------------------------------------------------------------
def day_text(data: dict) -> str:
steps = data["steps"]
sleep = data["sleep"]
hr = data["hr"]
spo2 = data["spo2"]
day_str = data["date"]
day_value = parse_day(day_str)
start_epoch, end_epoch = day_bounds(day_value)
day_label = relative_day_label(day_str)
lines = [f"📊 {esc(day_label)} · {esc(day_str)}", ""]
# Шаги
if steps:
steps_count = int(steps["total_steps"])
lines.extend(
[
"🚶 Активность:",
f" Шаги: {steps_count:,}".replace(",", " "),
f" {step_goal_bar(steps_count)}",
f" {step_goal_text(steps_count)}",
f" Дистанция: {float(steps['distance_m']) / 1000:.1f} км · {float(steps['calories']):.0f} ккал",
]
)
else:
lines.append("🚶 Шаги: за этот день данных нет")
lines.append("")
# Сон
if sleep:
total_sleep = sleep_total(sleep)
deep = int(sleep["deep_sleep_min"] or 0)
quality = sleep_quality_label(total_sleep, deep)
lines.extend(
[
f"😴 Сон: {format_minutes(total_sleep)} · {quality}",
f" Глубокий: {format_minutes(deep)} · Лёгкий: {format_minutes(sleep['light_sleep_min'])}"
+ (f" · REM: {format_minutes(sleep['rem_sleep_min'])}" if sleep['rem_sleep_min'] else ""),
f" Окно: {format_epoch(sleep['start_time'], False)} — {format_epoch(sleep['end_time'], False)}",
]
)
# Пульс покоя
rest_hr = resting_hr(int(sleep["start_time"]), int(sleep["end_time"]))
if rest_hr:
lines.append(f" Пульс покоя во сне: {rest_hr} bpm")
# Sparklines ЧСС и SpO2
sleep_hr_spark = get_sleep_sparkline(
"heart_rate", "value", int(sleep["start_time"]), int(sleep["end_time"])
)
sleep_spo2_spark = get_sleep_sparkline(
"blood_oxygen", "spo2", int(sleep["start_time"]), int(sleep["end_time"])
)
if sleep_hr_spark:
lines.append(f" 📈 ЧСС: [{sleep_hr_spark}]")
if sleep_spo2_spark:
lines.append(f" 📉 SpO2: [{sleep_spo2_spark}]")
else:
lines.append("😴 Сон: за этот день данных нет")
lines.append("")
lines.append("❤️ За сутки:")
if hr and hr["count"]:
lines.append(
f" Пульс: ср. {hr['avg_value']} · диапазон {hr['min_value']}–{hr['max_value']} bpm"
)
else:
lines.append(" Пульс: точек нет")
if spo2 and spo2["count"]:
lines.append(
f" SpO2: ср. {spo2['avg_value']}% · диапазон {spo2['min_value']}–{spo2['max_value']}%"
)
else:
lines.append(" SpO2: точек нет")
lines.extend(
["", "Статистика с датчиков браслета. Не является медицинским заключением."]
)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# History / Calendar
# ---------------------------------------------------------------------------
def history_text(days: int = 7) -> str:
summary = period_summary(days)
rows = summary["steps"]
sleep_by_day = {row["date"]: row for row in summary["sleep"]}
# Все уникальные дни из обоих источников
all_days = sorted(
set([r["date"] for r in rows] + list(sleep_by_day.keys())),
reverse=True,
)[:days]
lines = [
f"📅 Календарь · последние {days} дней",
f"{summary['start'].isoformat()} — {summary['end'].isoformat()}",
"",
]
if not all_days:
lines.append("Пока пусто: за этот период данных нет.")
return "\n".join(lines)
for d in all_days:
steps_row = next((r for r in rows if r["date"] == d), None)
sleep_row = sleep_by_day.get(d)
icon = day_emoji(steps_row, sleep_row)
steps_text = f"{int(steps_row['total_steps']):,} шагов".replace(",", " ") if steps_row else "шаги н/д"
sleep_text = "сон н/д"
if sleep_row:
total = sleep_total(sleep_row)
sleep_text = format_minutes(total)
lines.append(f"{icon} {d}: {steps_text} · {sleep_text}")
lines.append("")
lines.append("Нажми на кнопку дня ниже, чтобы открыть детальный разрез.")
return "\n".join(lines)
def history_keyboard(days: int = 7) -> InlineKeyboardMarkup:
# Переключатели периода
period_row = [
InlineKeyboardButton(
"· 7 дней ·" if days == 7 else "7 дней",
callback_data="period_cal:7",
),
InlineKeyboardButton(
"· 30 дней ·" if days == 30 else "30 дней",
callback_data="period_cal:30",
),
]
buttons: list[list[InlineKeyboardButton]] = [period_row]
day_buttons = [
InlineKeyboardButton(day, callback_data=f"day:{day}")
for day in available_days(days)
]
for idx in range(0, len(day_buttons), 3):
buttons.append(day_buttons[idx: idx + 3])
buttons.append([InlineKeyboardButton("⬅️ Главная", callback_data="menu:main")])
return InlineKeyboardMarkup(buttons)
# ---------------------------------------------------------------------------
# Day navigation keyboard
# ---------------------------------------------------------------------------
def day_keyboard(current: date) -> InlineKeyboardMarkup:
today = datetime.now(LOCAL_TZ).date()
prev_day = current - timedelta(days=1)
next_day = current + timedelta(days=1)
prev_btn = InlineKeyboardButton(f"◀️ {prev_day.isoformat()}", callback_data=f"day:{prev_day.isoformat()}")
if current >= today:
next_btn = InlineKeyboardButton("📊 Главная", callback_data="menu:main")
else:
next_btn = InlineKeyboardButton(f"{next_day.isoformat()} ▶️", callback_data=f"day:{next_day.isoformat()}")
return InlineKeyboardMarkup([
[prev_btn, next_btn],
[InlineKeyboardButton("📅 Календарь", callback_data="menu:history")],
])
# ---------------------------------------------------------------------------
# Sleep detail text
# ---------------------------------------------------------------------------
def latest_sleep_text() -> str:
sleep = latest_sleep()
if not sleep:
return "😴 Ночной сон\n\nПока нет данных."
hr, spo2 = sleep_window_stats(sleep)
total_sleep = sleep_total(sleep)
deep = int(sleep["deep_sleep_min"] or 0)
rem = int(sleep["rem_sleep_min"] or 0)
awake = int(sleep["awake_min"] or 0)
score = int(sleep["sleep_score"] or 0)
quality = sleep_quality_label(total_sleep, deep)
lines = [
f"😴 Ночной сон · {esc(relative_day_label(sleep['date']))}",
f"Дата: {esc(sleep['date'])}",
"",
f"⏳ Длительность: {format_minutes(total_sleep)} · {quality}"
+ (f" · Счёт: {score}/100" if score else ""),
f" • Глубокий: {format_minutes(deep)}",
f" • Лёгкий: {format_minutes(sleep['light_sleep_min'])}",
]
if rem:
lines.append(f" • REM: {format_minutes(rem)}")
if awake:
lines.append(f" • Пробуждений: {awake} мин")
lines.append(f" • Постель: {format_epoch(sleep['start_time'], False)} — {format_epoch(sleep['end_time'], False)}")
# Пульс покоя
if sleep["start_time"] and sleep["end_time"]:
rest_hr = resting_hr(int(sleep["start_time"]), int(sleep["end_time"]))
if rest_hr:
lines.append(f" • Пульс покоя: {rest_hr} bpm")
lines.append("")
# Sparklines
if sleep["start_time"] and sleep["end_time"]:
sleep_hr_spark = get_sleep_sparkline(
"heart_rate", "value", int(sleep["start_time"]), int(sleep["end_time"])
)
sleep_spo2_spark = get_sleep_sparkline(
"blood_oxygen", "spo2", int(sleep["start_time"]), int(sleep["end_time"])
)
if sleep_hr_spark:
lines.append("📈 ЧСС во сне:")
lines.append(f" [{sleep_hr_spark}]")
if hr and hr["count"]:
lines.append(
f" Ср. {hr['avg_value']} bpm · диапазон {hr['min_value']}–{hr['max_value']} bpm"
)
lines.append("")
else:
lines.append("📈 ЧСС: детальных точек нет\n")
if sleep_spo2_spark:
lines.append("📉 SpO2 во сне:")
lines.append(f" [{sleep_spo2_spark}]")
if spo2 and spo2["count"]:
lines.append(
f" Ср. {spo2['avg_value']}% · мин. {spo2['min_value']}%"
)
lines.append("")
else:
lines.append("📉 SpO2: детальных точек нет\n")
lines.append("Детали подтягиваются автоматически при фоновой синхронизации с FDS.")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Analytics / Trends
# ---------------------------------------------------------------------------
def period_text(days: int) -> str:
summary = period_summary(days)
steps = summary["steps"]
sleep_rows = summary["sleep"]
total_steps = sum(int(row["total_steps"] or 0) for row in steps)
avg_steps = round(total_steps / len(steps)) if steps else 0
total_distance = sum(float(row["distance_m"] or 0) for row in steps) / 1000
best_steps = max(steps, key=lambda r: int(r["total_steps"] or 0), default=None)
worst_steps = min(steps, key=lambda r: int(r["total_steps"] or 0), default=None)
sleep_totals = [
sleep_total(row) for row in sleep_rows
]
avg_sleep = round(sum(sleep_totals) / len(sleep_totals)) if sleep_totals else None
best_sleep = max(sleep_totals) if sleep_totals else None
goal_days = sum(1 for r in steps if int(r["total_steps"] or 0) >= STEP_GOAL)
hr = summary["hr"]
spo2 = summary["spo2"]
lines = [
f"📊 Аналитика · {days} дней",
f"{summary['start'].isoformat()} — {summary['end'].isoformat()}",
"",
"🚶 Активность:",
f" Всего шагов: {total_steps:,}".replace(",", " "),
f" В среднем: {avg_steps:,} / день".replace(",", " "),
f" Дистанция: {total_distance:.1f} км",
f" Норма {STEP_GOAL:,} выполнена: {goal_days} из {len(steps)} дней".replace(",", " "),
]
if best_steps:
lines.append(
f" 🏆 Лучший день: {best_steps['date']} · {int(best_steps['total_steps']):,} шагов".replace(",", " ")
)
if worst_steps and len(steps) > 1:
lines.append(
f" 📉 Слабейший: {worst_steps['date']} · {int(worst_steps['total_steps']):,} шагов".replace(",", " ")
)
lines.append("")
lines.append("😴 Сон:")
if avg_sleep is not None:
lines.append(f" Среднее: {format_minutes(avg_sleep)}")
else:
lines.append(" Среднее: н/д")
if best_sleep is not None:
lines.append(f" Лучшая ночь: {format_minutes(best_sleep)}")
lines.append("")
lines.append("❤️ ЧСС и SpO2:")
if hr and hr["count"]:
lines.append(
f" Пульс: ср. {hr['avg_value']} bpm · диапазон {hr['min_value']}–{hr['max_value']} bpm"
)
else:
lines.append(" Пульс: данных нет")
if spo2 and spo2["count"]:
lines.append(
f" SpO2: ср. {spo2['avg_value']}% · мин. {spo2['min_value']}%"
)
else:
lines.append(" SpO2: данных нет")
lines.extend(["", "Берегите здоровье!"])
return "\n".join(lines)
def trends_keyboard(days: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
[
[
InlineKeyboardButton(
"· 7 дней ·" if days == 7 else "7 дней",
callback_data="period:7d",
),
InlineKeyboardButton(
"· 30 дней ·" if days == 30 else "30 дней",
callback_data="period:30d",
),
],
[InlineKeyboardButton("⬅️ Главная", callback_data="menu:main")],
]
)
# ---------------------------------------------------------------------------
# Service menu
# ---------------------------------------------------------------------------
def more_text() -> str:
return (
"⚙️ Сервис\n\n"
"Технические функции: принудительная синхронизация, статус базы данных и выгрузка CSV."
)
def more_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
[
[InlineKeyboardButton("🔄 Принудительный синк", callback_data="menu:sync")],
[
InlineKeyboardButton("💾 Экспорт ZIP", callback_data="menu:export"),
InlineKeyboardButton("🧰 Статус БД", callback_data="menu:db_status"),
],
[InlineKeyboardButton("⬅️ Главная", callback_data="menu:main")],
]
)
def db_status_text() -> str:
status = read_status_file()
lines = ["🧰 Статус базы данных", ""]
if not health_db_exists():
return "🧰 Статус базы данных\n\nБаза пока не создана."
conn = health_conn()
try:
tables = ["steps_daily", "sleep_daily", "sleep_stages", "heart_rate", "blood_oxygen"]
for table in tables:
exists = conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
).fetchone()
if not exists:
lines.append(f"• {table}: таблицы нет")
continue
count = conn.execute(f"SELECT COUNT(*) AS count FROM {table}").fetchone()["count"]
lines.append(f"• {table}: {count:,} строк".replace(",", " "))
finally:
conn.close()
last_sync_epoch = status.get("last_sync")
last_sync = format_epoch(last_sync_epoch) if last_sync_epoch else status.get("last_sync_time", "н/д")
lines.extend(
[
"",
f"Путь: {esc(DB_PATH)}",
f"Последний синк: {esc(last_sync)}",
]
)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
def zip_export() -> io.BytesIO:
return storage.zip_export(SETTINGS, ALLOWED_USER_ID)
# ---------------------------------------------------------------------------
# Xiaomi onboarding
# ---------------------------------------------------------------------------
def onboarding_text() -> str:
return (
"🔐 Авторизация Xiaomi\n\n"
"Для первого запуска нужен вход в Xiaomi Fitness. Нажми кнопку ниже, подтверди вход, "
"а я дождусь ответа и сразу запущу синхронизацию."
)
def onboarding_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
[[InlineKeyboardButton("🔐 Войти в Xiaomi", callback_data="auth:start")]]
)
def normalize_login_url(url: str | None) -> str:
if not url:
return ""
url = url.strip()
if url.startswith("//"):
return f"https:{url}"
return url
def xiaomi_wait_keyboard(login_url: str, qr_image_url: str) -> InlineKeyboardMarkup:
rows: list[list[InlineKeyboardButton]] = []
login_url = normalize_login_url(login_url)
qr_image_url = normalize_login_url(qr_image_url)
if login_url:
rows.append([InlineKeyboardButton("🔐 Открыть вход Xiaomi", url=login_url)])
if qr_image_url:
rows.append([InlineKeyboardButton("▦ Открыть QR-код", url=qr_image_url)])
return InlineKeyboardMarkup(rows or [[InlineKeyboardButton("🔄 Повторить", callback_data="auth:start")]])
def auth_retry_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
[
[InlineKeyboardButton("🔐 Войти заново", callback_data="auth:relogin")],
[InlineKeyboardButton("⬅️ Сервис", callback_data="menu:more")],
]
)
async def show_onboarding(update: Update, context: ContextTypes.DEFAULT_TYPE, force_new: bool = False) -> None:
await update_menu(update, context, onboarding_text(), onboarding_keyboard(), force_new=force_new)
async def start_xiaomi_login(update: Update, context: ContextTypes.DEFAULT_TYPE, *, force: bool = False) -> None:
if has_xiaomi_token() and not force:
await show_main_menu(update, context)
return
if AUTH_LOCK.locked():
await update_menu(
update,
context,
"🔐 Авторизация Xiaomi\n\nУже жду подтверждение входа. Открой ссылку из предыдущего сообщения.",
onboarding_keyboard(),
)
return
async with AUTH_LOCK:
if has_xiaomi_token() and not force:
await show_main_menu(update, context)
return
await update_menu(
update,
context,
"🔐 Авторизация Xiaomi\n\nГотовлю ссылку входа…",
None,
)
auth = XiaomiAuth()
async def qr_callback(qr_image_url: str, login_url: str) -> None:
await update_menu(
update,
context,
"🔐 Авторизация Xiaomi\n\nОткрой ссылку, подтверди вход и вернись сюда. Я жду результат.",
xiaomi_wait_keyboard(login_url, qr_image_url),
)
try:
token = await auth.login_qr(qr_callback=qr_callback, max_wait=300)
token_path = get_xiaomi_token_path()
if token_path is None:
raise ConfigError("Не удалось определить путь для Xiaomi token")
save_auth_token(token, token_path)
await update_menu(
update,
context,
"✅ Авторизация Xiaomi\n\nВход подтверждён. Запускаю первую синхронизацию…",
None,
)
await run_initial_sync_after_login(update, context)
except Exception as e:
logger.exception("Xiaomi login failed")
await update_menu(
update,
context,
f"⚠️ Авторизация Xiaomi\n\nНе удалось войти: {esc(e)}",
auth_retry_keyboard(),
)
finally:
await auth.close()
async def run_initial_sync_after_login(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if SYNC_LOCK.locked():
await update_menu(
update,
context,
"✅ Авторизация Xiaomi\n\nВход готов. Синхронизация уже идёт, открою меню.",
main_keyboard(),
)
return
async with SYNC_LOCK:
result = await run_sync(ALLOWED_USER_ID, SETTINGS)
if result.success:
await show_main_menu(update, context)
return
detail = f"\n\nПричина: {esc(result.error)}" if result.error else ""
await update_menu(
update,
context,
f"⚠️ Первая синхронизация\n\nАвторизация сохранена, но данные не обновились.{detail}",
auth_retry_keyboard(),
)
# ---------------------------------------------------------------------------
# Menu keyboards
# ---------------------------------------------------------------------------
def main_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
[
[
InlineKeyboardButton("📅 Календарь", callback_data="menu:history"),
InlineKeyboardButton("📊 Аналитика", callback_data="menu:trends"),
],
[
InlineKeyboardButton("😴 Детали сна", callback_data="menu:sleep"),
InlineKeyboardButton("⚙️ Сервис", callback_data="menu:more"),
],
]
)
def back_keyboard(back_to: str = "menu:main") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data=back_to)]])
# ---------------------------------------------------------------------------
# Handlers: show_* functions
# ---------------------------------------------------------------------------
async def show_main_menu(
update: Update, context: ContextTypes.DEFAULT_TYPE, force_new: bool = False
) -> None:
await update_menu(update, context, main_menu_text(), main_keyboard(), force_new=force_new)
async def show_history(
update: Update, context: ContextTypes.DEFAULT_TYPE, days: int = 7
) -> None:
await update_menu(update, context, history_text(days), history_keyboard(days))
async def show_day(
update: Update, context: ContextTypes.DEFAULT_TYPE, day_str: str
) -> None:
day_value = parse_day(day_str)
await update_menu(
update,
context,
day_text(day_summary(day_str)),
day_keyboard(day_value),
)
async def run_manual_sync(
update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
if not has_xiaomi_token():
await show_onboarding(update, context)
return
if not run_sync:
await update_menu(
update,
context,
"🔄 Синхронизация\n\nМодуль синхронизации не найден.",
back_keyboard("menu:more"),
)
return
if SYNC_LOCK.locked():
await update_menu(
update,
context,
"🔄 Синхронизация\n\nУже идёт обновление. Подожди завершения.",
back_keyboard("menu:more"),
)
return
await update_menu(
update,
context,
"🔄 Синхронизация\n\nЗабираю данные из Xiaomi Fitness…",
back_keyboard("menu:more"),
)
async with SYNC_LOCK:
try:
result = await run_sync(ALLOWED_USER_ID, SETTINGS)
except Exception as e:
logger.exception("Manual sync failed")
await update_menu(
update,
context,
f"🔄 Синхронизация\n\nОшибка запуска: {esc(e)}",
back_keyboard("menu:more"),
)
return
if result.success:
await update_menu(
update,
context,
"✅ Синхронизация\n\nГотово, данные обновлены.",
InlineKeyboardMarkup(
[
[InlineKeyboardButton("📊 На главную", callback_data="menu:main")],
[InlineKeyboardButton("⬅️ Сервис", callback_data="menu:more")],
]
),
)
else:
detail = f"\n\nПричина: {esc(result.error)}" if result.error else ""
keyboard = auth_retry_keyboard() if "Token" in (result.error or "") else back_keyboard("menu:more")
await update_menu(
update,
context,
f"⚠️ Синхронизация\n\nНе вышло обновиться.{detail}",
keyboard,
)
async def export_data(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.effective_chat:
return
if not health_db_exists():
await update_menu(
update,
context,
"💾 Экспорт\n\nБаза данных пока не создана.",
back_keyboard("menu:more"),
)
return
await update_menu(update, context, "💾 Экспорт\n\nСобираю ZIP…", None)
try:
export_file = zip_export()
if export_file.getbuffer().nbytes == 0:
await update_menu(
update,
context,
"💾 Экспорт\n\nВ базе нет данных.",
back_keyboard("menu:more"),
)
return
await context.bot.send_document(
chat_id=update.effective_chat.id,
document=export_file,
filename=export_file.name,
caption="💚 MiBand Health CSV Export",
)
await update_menu(
update,
context,
"✅ Экспорт\n\nZIP с CSV-таблицами отправлен выше.",
back_keyboard("menu:more"),
)
except Exception as e:
logger.exception("Export failed")
await update_menu(
update,
context,
f"⚠️ Экспорт\n\nОшибка: {esc(e)}",
back_keyboard("menu:more"),
)
# ---------------------------------------------------------------------------
# Command handlers
# ---------------------------------------------------------------------------
@with_user_context
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not is_allowed(update):
uid = update.effective_user.id if update.effective_user else "unknown"
logger.warning("Unauthorized access from user: %s", uid)
return
await safe_delete(update.message)
if not has_xiaomi_token():
await show_onboarding(update, context, force_new=True)
return
await show_main_menu(update, context, force_new=True)
@with_user_context
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not is_allowed(update):
return
await safe_delete(update.message)
await update_menu(update, context, db_status_text(), back_keyboard("menu:more"))
@with_user_context
async def cmd_sync(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not is_allowed(update):
return
await safe_delete(update.message)
if not has_xiaomi_token():
await show_onboarding(update, context)
return
await run_manual_sync(update, context)
@with_user_context
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not is_allowed(update):
return
await safe_delete(update.message)
if not has_xiaomi_token():
await show_onboarding(update, context)
return
await show_main_menu(update, context)
# ---------------------------------------------------------------------------
# Callback router
# ---------------------------------------------------------------------------
@with_user_context
async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
if query:
await query.answer()
if not is_allowed(update):
return
data = query.data if query else "menu:main"
if data in {"auth:start", "auth:relogin"}:
await start_xiaomi_login(update, context, force=data == "auth:relogin")
elif not has_xiaomi_token():
await show_onboarding(update, context)
elif data == "menu:main":
await show_main_menu(update, context)
elif data == "menu:history":
await show_history(update, context, 7)
elif data.startswith("period_cal:"):
# Переключение числа дней в Календаре
days = int(data.split(":")[1])
await show_history(update, context, days)
elif data == "menu:sleep":
await update_menu(
update,
context,
latest_sleep_text(),
InlineKeyboardMarkup(
[
[InlineKeyboardButton("📅 Календарь", callback_data="menu:history")],
[InlineKeyboardButton("⬅️ Главная", callback_data="menu:main")],
]
),
)
elif data == "menu:trends":
await update_menu(update, context, period_text(30), trends_keyboard(30))
elif data.startswith("period:"):
days = 7 if data == "period:7d" else 30
await update_menu(update, context, period_text(days), trends_keyboard(days))
elif data.startswith("day:"):
try:
await show_day(update, context, data.split(":", 1)[1])
except Exception as e:
logger.exception("Failed to show day")
await update_menu(
update,
context,
f"📊 День\n\nНе удалось открыть: {esc(e)}",
back_keyboard("menu:history"),
)
elif data == "menu:more":
await update_menu(update, context, more_text(), more_keyboard())
elif data == "menu:sync":
await run_manual_sync(update, context)
elif data == "menu:export":
await export_data(update, context)
elif data == "menu:db_status":
await update_menu(update, context, db_status_text(), back_keyboard("menu:more"))
else:
await show_main_menu(update, context)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
global SETTINGS, BOT_TOKEN, ALLOWED_USER_ID, DB_PATH
try:
SETTINGS = Settings.from_env(require_bot=True)
except ConfigError as exc:
logger.error("%s", exc)
sys.exit(1)
BOT_TOKEN = SETTINGS.telegram_bot_token
ALLOWED_USER_ID = SETTINGS.require_user_id()
DB_PATH = str(SETTINGS.user_db_path(ALLOWED_USER_ID))
init_state_db()
logger.info("Стартую fitness-bot для пользователя: %s", ALLOWED_USER_ID)
app = Application.builder().token(BOT_TOKEN).build()
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("status", cmd_status))
app.add_handler(CommandHandler("sync", cmd_sync))
app.add_handler(CallbackQueryHandler(handle_callback))
app.add_handler(MessageHandler(filters.ALL, handle_message))
app.run_polling(drop_pending_updates=True)
if __name__ == "__main__":
main()