Files
2026-05-26 16:28:07 +03:00

1805 lines
64 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
from __future__ import annotations
import asyncio
import io
import logging
import os
import sqlite3
import sys
from datetime import date, datetime, timedelta
from functools import wraps
from pathlib import Path
from mi_fitness.auth import XiaomiAuth
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Message, Update
from telegram.error import RetryAfter, TelegramError
from telegram.ext import (
Application,
CallbackQueryHandler,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
from miband_tracker import storage
from miband_tracker.bot.formatting import (
LOCAL_TZ,
RU_MONTHS,
day_bounds,
esc,
format_epoch,
format_minutes,
format_relative_time,
make_sleep_bar,
make_sparkline,
parse_day,
relative_day_label,
sleep_total,
workout_type_label,
)
from miband_tracker.config import ConfigError, Settings
from miband_tracker.secure_files import save_auth_token
from miband_tracker.sync import run_sync
# ---------------------------------------------------------------------------
# Logging — console shows only WARNING+ so users don't see debug spam.
# The mi-fitness vendored library uses loguru with Chinese debug messages;
# we suppress those to ERROR as well.
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.WARNING,
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")
try:
from loguru import logger as _loguru_logger
_loguru_logger.remove() # Remove loguru's default stderr handler
_loguru_logger.add(sys.stderr, level="ERROR", format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}")
except Exception:
pass
# ---------------------------------------------------------------------------
# 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)
SYNC_LOCK = asyncio.Lock()
AUTH_LOCK = asyncio.Lock()
AUTO_MENU_REFRESH_INTERVAL = max(5, int(os.getenv("AUTO_MENU_REFRESH_INTERVAL", "30")))
STEP_GOAL = 10_000 # можно вынести в env при желании
# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
def is_allowed(update: Update) -> bool:
global ALLOWED_USER_ID
uid = update.effective_user.id if update.effective_user else None
if uid is None:
return False
if ALLOWED_USER_ID is None:
ALLOWED_USER_ID = uid
try:
allowed_user_file = SETTINGS.data_dir / "allowed_user.id"
SETTINGS.data_dir.mkdir(parents=True, exist_ok=True)
allowed_user_file.write_text(str(uid), encoding="utf-8")
logger.info("🎉 Бот успешно привязан к первому пользователю (ID: %s)!", uid)
except Exception as e:
logger.error("Не удалось сохранить ID владельца в файл: %s", e)
return True
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 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)
# ---------------------------------------------------------------------------
# Daily tip engine
# ---------------------------------------------------------------------------
def daily_tip(steps: sqlite3.Row | None, sleep: sqlite3.Row | None, hr: sqlite3.Row | None) -> str:
"""Генерирует один персональный инсайт на основе последних данных."""
tips = []
is_en = os.getenv("BOT_LANG") == "en"
if steps:
s = int(steps["total_steps"] or 0)
if s < 5000:
if is_en:
tips.append("💡 Very few steps today. A 20-minute walk will add ~2 000 steps and boost your mood.")
else:
tips.append("💡 Сегодня совсем мало шагов. Прогулка в 20 минут добавит ~2 000 шагов и заметно поднимет настроение.")
elif s >= STEP_GOAL:
if is_en:
tips.append("💡 Daily step goal reached — excellent job!")
else:
tips.append("💡 Дневная норма шагов выполнена — отличный результат!")
elif s >= 7000:
if is_en:
tips.append(f"💡 {STEP_GOAL - s:,} steps left to reach your goal of {STEP_GOAL:,} — almost there!".replace(",", " "))
else:
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:
if is_en:
tips.append("💡 Sleep was under 5 hours — that's too short. Try to go to bed earlier tonight.")
else:
tips.append("💡 Ночной сон меньше 5 часов — это мало. Постарайтесь лечь пораньше сегодня.")
elif total < 360:
if is_en:
tips.append("💡 Sleep was under 6 hours. Try to go to bed earlier tonight.")
else:
tips.append("💡 Ночной сон меньше 6 часов. Постарайтесь лечь пораньше сегодня.")
elif deep < 30:
if is_en:
tips.append("💡 Very little deep sleep. Try airing out the room and avoiding screens for an hour before bed.")
else:
tips.append("💡 Глубокого сна было совсем мало. Попробуйте проветрить комнату и ограничить экраны за час до сна.")
if hr:
bpm = int(hr["value"])
if bpm > 90:
if is_en:
tips.append("💡 Resting heart rate is elevated — the body might be tired or stressed. Keep an eye on how you feel.")
else:
tips.append("💡 Пульс в покое выше нормы — возможно, организм устал или есть стресс. Следите за самочувствием.")
elif bpm < 50:
if is_en:
tips.append("💡 Very low heart rate — if you are an athlete, it's fine. Otherwise, pay close attention.")
else:
tips.append("💡 Очень низкий пульс — если это спортивная норма, всё хорошо. Если нет — стоит обратить внимание.")
if not tips:
if is_en:
tips.append("💡 Enough data, keep up the good work!")
else:
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)
async def auto_refresh_main_menu_loop(app: Application) -> None:
"""Refresh the pinned main menu after the sync daemon writes a new status file."""
if ALLOWED_USER_ID is None:
return
last_seen_mtime: float | None = None
while True:
try:
status_path = SETTINGS.user_status_path(ALLOWED_USER_ID)
if status_path.exists():
current_mtime = status_path.stat().st_mtime
if last_seen_mtime is None:
last_seen_mtime = current_mtime
elif current_mtime > last_seen_mtime:
last_seen_mtime = current_mtime
if get_user_menu_msg_id(ALLOWED_USER_ID):
await send_or_update_menu(
app.bot,
ALLOWED_USER_ID,
main_menu_text(),
main_keyboard(),
)
logger.info("Auto-refreshed main menu for user %s", ALLOWED_USER_ID)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Auto menu refresh failed: %s", exc)
await asyncio.sleep(AUTO_MENU_REFRESH_INTERVAL)
async def start_background_tasks(app: Application) -> None:
app.bot_data["auto_refresh_main_menu_task"] = asyncio.create_task(
auto_refresh_main_menu_loop(app),
name="auto-refresh-main-menu",
)
async def stop_background_tasks(app: Application) -> None:
task = app.bot_data.get("auto_refresh_main_menu_task")
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# ---------------------------------------------------------------------------
# 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 latest_stress() -> sqlite3.Row | None:
return fetch_one("SELECT timestamp, value FROM stress ORDER BY timestamp DESC LIMIT 1")
def latest_weight() -> sqlite3.Row | None:
return fetch_one("SELECT timestamp, weight_kg FROM weight ORDER BY timestamp DESC LIMIT 1")
def latest_calories() -> sqlite3.Row | None:
return fetch_one(
"""
SELECT date, total_cal, valid_stand_hours, intensity_minutes
FROM calories_daily
ORDER BY date DESC
LIMIT 1
"""
)
def recent_workouts(limit: int = 5) -> list[sqlite3.Row]:
return fetch_all(
"""
SELECT workout_id, sport_type, start_time, end_time,
duration_sec, calories, avg_hr, max_hr, min_hr
FROM workouts
ORDER BY start_time DESC
LIMIT ?
""",
(limit,),
)
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)
stress = metric_stats("stress", "value", start_epoch, end_epoch)
calories = fetch_one(
"""
SELECT total_cal, active_cal, valid_stand_hours, intensity_minutes
FROM calories_daily
WHERE date = ?
""",
(day_str,),
)
weight = fetch_one(
"""
SELECT weight_kg, bmi, body_fat_pct
FROM weight
WHERE timestamp <= ?
ORDER BY timestamp DESC
LIMIT 1
""",
(end_epoch,),
)
workouts = fetch_all(
"""
SELECT workout_id, sport_type, start_time, end_time, duration_sec, calories, avg_hr, max_hr, min_hr
FROM workouts
WHERE start_time >= ? AND start_time < ?
ORDER BY start_time ASC
""",
(start_epoch, end_epoch),
)
return {
"date": day_str,
"steps": steps,
"sleep": sleep,
"hr": hr,
"spo2": spo2,
"stress": stress,
"calories": calories,
"weight": weight,
"workouts": workouts,
}
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)
stress = metric_stats("stress", "value", start_epoch, end_epoch)
calories_rows = fetch_all(
"""
SELECT date, total_cal, active_cal, valid_stand_hours, intensity_minutes
FROM calories_daily
WHERE date BETWEEN ? AND ?
ORDER BY date DESC
""",
(start_day.isoformat(), end_day.isoformat()),
)
weight_rows = fetch_all(
"""
SELECT timestamp, weight_kg, bmi, body_fat_pct
FROM weight
WHERE timestamp >= ? AND timestamp < ?
ORDER BY timestamp DESC
""",
(start_epoch, end_epoch),
)
if not weight_rows:
latest_w = fetch_one(
"""
SELECT timestamp, weight_kg, bmi, body_fat_pct
FROM weight
ORDER BY timestamp DESC
LIMIT 1
"""
)
weight_rows = [latest_w] if latest_w else []
return {
"days": days,
"start": start_day,
"end": end_day,
"steps": steps,
"sleep": sleep,
"hr": hr,
"spo2": spo2,
"stress": stress,
"calories": calories_rows,
"weight": weight_rows,
}
# ---------------------------------------------------------------------------
# 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()
stress = latest_stress()
lines = []
# 1. Шаги
if steps:
steps_count = int(steps["total_steps"])
dist_km = float(steps['distance_m']) / 1000.0
cals = float(steps['calories'])
lines.append(f"🚶 <b>{steps_count:,}</b> · <b>{dist_km:.1f}</b> км · <b>{cals:.0f}</b> ккал".replace(",", " "))
else:
lines.append("🚶 Шаги н/д")
lines.append("")
# 2. Сон
if sleep:
total_sleep = sleep_total(sleep)
try:
start_str = format_epoch(sleep["start_time"], False)
end_str = format_epoch(sleep["end_time"], False)
time_arrow = f"{start_str}{end_str} · "
except Exception:
time_arrow = ""
lines.append(f"😴 {time_arrow}<b>{format_minutes(total_sleep)}</b>")
else:
lines.append("😴 Сон н/д")
lines.append("")
# 3. Пульс, Кислород, Стресс
metrics_parts = []
if hr:
metrics_parts.append(f"❤️ <b>{int(hr['value'])}</b>")
if spo2:
metrics_parts.append(f"🩸 <b>{float(spo2['spo2']):.0f}%</b>")
if stress:
metrics_parts.append(f"🧘 <b>{int(stress['value'])}</b>")
if metrics_parts:
lines.append(" · ".join(metrics_parts))
else:
lines.append("❤️ 🩸 🧘 н/д")
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_label = relative_day_label(day_str)
# Заголовок без эмодзи, затем пустая строка
lines = [f"Детали за {esc(day_str)} ({esc(day_label)})", ""]
# Шаги
if steps:
steps_count = int(steps["total_steps"])
dist_km = float(steps['distance_m']) / 1000.0
lines.append(f"🚶 <b>Шаги:</b> {steps_count:,} · {dist_km:.1f} км".replace(",", " "))
else:
lines.append("🚶 Шаги: за этот день данных нет")
# Активность и Калории
cals_row = data.get("calories")
if cals_row:
total_cal = float(cals_row["total_cal"] or 0)
active_cal = float(cals_row["active_cal"] or 0)
stand_hours = int(cals_row["valid_stand_hours"] or 0)
intensity_min = int(cals_row["intensity_minutes"] or 0)
lines.append(f"🧍 <b>Активность:</b> разминки: {stand_hours}ч · интенсивность: {intensity_min} мин")
lines.append(f"🔥 <b>Энергия:</b> всего: {total_cal:.0f} ккал (активные: {active_cal:.0f} ккал)")
elif steps:
cals = float(steps['calories'])
lines.append(f"🔥 <b>Энергия:</b> {cals:.0f} ккал")
lines.append("")
# Сон
if sleep:
total_sleep = sleep_total(sleep)
deep = int(sleep["deep_sleep_min"] or 0)
light = int(sleep["light_sleep_min"] or 0)
score = int(sleep["sleep_score"] or 0)
score_part = f" · {score}/100" if score else ""
try:
start_str = format_epoch(sleep["start_time"], False)
end_str = format_epoch(sleep["end_time"], False)
time_arrow = f" · {start_str}{end_str}"
except Exception:
time_arrow = ""
lines.append(f"😴 <b>Сон:</b> {format_minutes(total_sleep)} (глубокий: {format_minutes(deep)} · легкий: {format_minutes(light)}){score_part}{time_arrow}")
else:
lines.append("😴 Сон: за этот день данных нет")
lines.append("")
# Показатели (Пульс, SpO2, Стресс, Вес)
metrics_parts = []
if hr and hr["count"]:
avg_hr = int(hr["avg_value"])
min_hr = int(hr["min_value"])
max_hr = int(hr["max_value"])
rest_hr_part = ""
if sleep:
rest_hr = resting_hr(int(sleep["start_time"]), int(sleep["end_time"]))
if rest_hr:
rest_hr_part = f" · во сне: {rest_hr} bpm"
metrics_parts.append(f"❤️ <b>Пульс:</b> ср. {avg_hr} ({min_hr}{max_hr}) bpm{rest_hr_part}")
else:
metrics_parts.append("❤️ Пульс: за этот день данных нет")
# SpO2
if spo2 and spo2["count"]:
avg_spo2 = float(spo2["avg_value"])
min_spo2 = float(spo2["min_value"])
max_spo2 = float(spo2["max_value"])
metrics_parts.append(f"🩸 <b>Кислород:</b> ср. {avg_spo2:.0f}% ({min_spo2:.0f}{max_spo2:.0f}%) SpO2")
else:
metrics_parts.append("🩸 Кислород: за этот день данных нет")
# Стресс
stress = data.get("stress")
if stress and stress["count"]:
avg_str = int(stress["avg_value"])
min_str = int(stress["min_value"])
max_str = int(stress["max_value"])
metrics_parts.append(f"🧘 <b>Стресс:</b> ср. {avg_str} ({min_str}{max_str})")
else:
metrics_parts.append("🧘 Стресс: за этот день данных нет")
# Вес
weight = data.get("weight")
if weight:
w_kg = float(weight["weight_kg"] or 0)
bmi_part = ""
fat_part = ""
if weight["bmi"]:
bmi_part = f" · BMI: {float(weight['bmi']):.1f}"
if weight["body_fat_pct"]:
fat_part = f" · жир: {float(weight['body_fat_pct']):.1f}%"
metrics_parts.append(f"⚖️ <b>Вес:</b> {w_kg:.1f} кг{bmi_part}{fat_part}")
lines.append("\n\n".join(metrics_parts))
# Тренировки
workouts = data.get("workouts")
if workouts:
lines.append("")
lines.append("🏋️ <b>Тренировки:</b>")
for w in workouts:
sport = workout_type_label(w["sport_type"])
dur_min = int(w["duration_sec"] or 0) // 60
dur_sec = int(w["duration_sec"] or 0) % 60
dur_str = f"{dur_min}:{dur_sec:02d}"
cal = float(w["calories"] or 0)
avg_hr = int(w["avg_hr"] or 0)
max_hr = int(w["max_hr"] or 0)
hr_part = ""
if avg_hr:
hr_part = f" · ❤️ ср. {avg_hr}"
if max_hr and max_hr != avg_hr:
hr_part += f" (макс {max_hr})"
hr_part += " bpm"
try:
start_dt = datetime.fromtimestamp(w["start_time"], LOCAL_TZ)
time_str = f" в {start_dt.hour:02d}:{start_dt.minute:02d}"
except Exception:
time_str = ""
lines.append(f"• <b>{esc(sport)}</b>{time_str} ({dur_str} · 🔥 {cal:.0f} ккал{hr_part})")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# History / Calendar
# ---------------------------------------------------------------------------
def day_btn_label(date_str: str) -> str:
try:
dt = datetime.strptime(date_str, "%Y-%m-%d").date()
month_name = RU_MONTHS[dt.month - 1]
return f"{dt.day} {month_name}"
except Exception:
return date_str
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"📅 <b>Последние {days} дней</b>",
"",
]
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)
try:
dt = datetime.strptime(d, "%Y-%m-%d").date()
date_formatted = f"{dt.day:02d}.{dt.month:02d}"
except Exception:
date_formatted = d
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} <b>{date_formatted}</b> {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_btn_label(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 "😴 <b>Ночной сон</b>\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)
score = int(sleep["sleep_score"] or 0)
import datetime as _dt
try:
sleep_date = _dt.datetime.strptime(sleep["date"], "%Y-%m-%d").date()
date_formatted = f"{sleep_date.day} {RU_MONTHS[sleep_date.month - 1]}"
except Exception:
date_formatted = sleep["date"]
start_str = format_epoch(sleep["start_time"], False)
end_str = format_epoch(sleep["end_time"], False)
rest_hr_str = "н/д"
if sleep["start_time"] and sleep["end_time"]:
rest_hr = resting_hr(int(sleep["start_time"]), int(sleep["end_time"]))
if rest_hr:
rest_hr_str = f"{rest_hr} bpm"
deep_bar = make_sleep_bar(deep, total_sleep)
light = int(sleep["light_sleep_min"] or 0)
light_bar = make_sleep_bar(light, total_sleep)
rem_bar = make_sleep_bar(rem, total_sleep)
lines = [
f"😴 <b>Ночной сон · {date_formatted}</b>",
"",
f"Длительность <b>{format_minutes(total_sleep)}</b>",
f"Качество <b>{score} / 100</b>" if score else "Качество <b>н/д</b>",
f"Постель <b>{start_str}{end_str}</b>",
f"Пульс покоя <b>{rest_hr_str}</b>",
"",
f"Глубокий <code>{deep_bar}</code> {format_minutes(deep)}",
f"Лёгкий <code>{light_bar}</code> {format_minutes(light)}",
]
if rem:
lines.append(f"REM <code>{rem_bar}</code> {format_minutes(rem)}")
lines.append("")
hr_str = "н/д"
if hr and hr["count"]:
hr_str = f"ср. {int(hr['avg_value'])} · диапазон {int(hr['min_value'])}{int(hr['max_value'])}"
lines.append(f"ЧСС во сне {hr_str}")
spo2_str = "н/д"
if spo2 and spo2["count"]:
spo2_str = f"ср. {int(spo2['avg_value'])}% · мин. {int(spo2['min_value'])}%"
lines.append(f"SpO2 во сне {spo2_str}")
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
best_steps = max(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"]
stress = summary["stress"]
cals_rows = summary["calories"]
weight_rows = summary["weight"]
start_date = summary["start"]
end_date = summary["end"]
if start_date.month == end_date.month:
month_name = RU_MONTHS[start_date.month - 1]
date_range = f"{start_date.day}{end_date.day} {month_name}"
else:
start_month = RU_MONTHS[start_date.month - 1]
end_month = RU_MONTHS[end_date.month - 1]
date_range = f"{start_date.day} {start_month}{end_date.day} {end_month}"
avg_total_cal = None
avg_active_cal = None
avg_stand_hours = None
avg_intensity_min = None
if cals_rows:
valid_cals = [float(r["total_cal"]) for r in cals_rows if r["total_cal"] is not None]
valid_active = [float(r["active_cal"]) for r in cals_rows if r["active_cal"] is not None]
valid_stand = [int(r["valid_stand_hours"]) for r in cals_rows if r["valid_stand_hours"] is not None]
valid_intensity = [int(r["intensity_minutes"]) for r in cals_rows if r["intensity_minutes"] is not None]
if valid_cals:
avg_total_cal = round(sum(valid_cals) / len(valid_cals))
if valid_active:
avg_active_cal = round(sum(valid_active) / len(valid_active))
if valid_stand:
avg_stand_hours = round(sum(valid_stand) / len(valid_stand))
if valid_intensity:
avg_intensity_min = round(sum(valid_intensity) / len(valid_intensity))
stress_str = "н/д"
if stress and stress["count"]:
stress_str = f"ср. {int(stress['avg_value'])} · диапазон {int(stress['min_value'])}{int(stress['max_value'])}"
weight_str = None
if weight_rows:
latest_weight = weight_rows[0]
w_kg = float(latest_weight["weight_kg"] or 0)
bmi_val = latest_weight["bmi"]
fat_val = latest_weight["body_fat_pct"]
weight_str = f"{w_kg:.1f} кг"
if bmi_val:
weight_str += f" (BMI: {float(bmi_val):.1f}"
if fat_val:
weight_str += f" · жир: {float(fat_val):.1f}%"
weight_str += ")"
period_label = "Все время" if days >= 3650 else f"{days} дней"
lines = [
f"📊 <b>Тренды · {period_label} · {date_range}</b>",
"",
f"🚶 <b>Шаги всего</b> {total_steps:,}".replace(",", " "),
f" В среднем / день {avg_steps:,}".replace(",", " "),
f" Норма {STEP_GOAL // 1000}k {goal_days} из {len(steps)} дней",
]
if best_steps:
try:
best_dt = datetime.strptime(best_steps["date"], "%Y-%m-%d").date()
best_date_str = f"{best_dt.day:02d}.{best_dt.month:02d}"
except Exception:
best_date_str = best_steps["date"]
lines.append(f" 🏆 Лучший день {best_date_str} · {int(best_steps['total_steps']):,}".replace(",", " "))
lines.append("")
if avg_total_cal is not None:
lines.append("🧍 <b>Активность ср.</b>")
lines.append(f" Расход энергии {avg_total_cal} ккал (активные: {avg_active_cal} ккал)")
stand_part = f"{avg_stand_hours}ч" if avg_stand_hours is not None else "н/д"
intens_part = f"{avg_intensity_min} мин" if avg_intensity_min is not None else "н/д"
lines.append(f" Часы разминок {stand_part} · интенсивность: {intens_part}")
lines.append("")
lines.append("😴 <b>Сон среднее</b> " + (format_minutes(avg_sleep) if avg_sleep else "н/д"))
if best_sleep:
lines.append(f" Лучшая ночь {format_minutes(best_sleep)}")
lines.append("")
hr_str = "н/д"
if hr and hr["count"]:
hr_str = f"{int(hr['avg_value'])} bpm · {int(hr['min_value'])}{int(hr['max_value'])}"
lines.append(f"❤️ <b>Пульс ср.</b> {hr_str}")
spo2_str = "н/д"
if spo2 and spo2["count"]:
spo2_str = f"{float(spo2['avg_value']):.1f}% · мин. {int(spo2['min_value'])}%"
lines.append(f"🩸 <b>SpO2 ср.</b> {spo2_str}")
# Стресс
lines.append(f"🧘 <b>Стресс ср.</b> {stress_str}")
# Вес
if weight_str:
lines.append(f"⚖️ <b>Вес (последний)</b> {weight_str}")
lines.extend(["", "<i>Берегите здоровье!</i>"])
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("· Все время ·" if days >= 3650 else "Все время", callback_data="period:all"),
InlineKeyboardButton("🏋️ Тренировки", callback_data="menu:workouts"),
],
[InlineKeyboardButton("⬅️ Главная", callback_data="menu:main")],
]
)
# ---------------------------------------------------------------------------
# Workouts screen
# ---------------------------------------------------------------------------
def workouts_text(limit: int = 10) -> str:
workouts = recent_workouts(limit)
lines = ["🏋️ <b>Тренировки</b>", ""]
if not workouts:
lines.append("Пока нет записей. Синхронизация загрузит тренировки автоматически.")
return "\n".join(lines)
for w in workouts:
sport = workout_type_label(w["sport_type"])
start = format_epoch(w["start_time"])
dur_min = int(w["duration_sec"] or 0) // 60
dur_sec = int(w["duration_sec"] or 0) % 60
dur_str = f"{dur_min}:{dur_sec:02d}"
cal = float(w["calories"] or 0)
avg_hr = int(w["avg_hr"] or 0)
max_hr = int(w["max_hr"] or 0)
lines.append(f"🏋️ <b>{esc(sport)}</b>")
lines.append(f" 🗓 {esc(start)}")
lines.append(f"{dur_str} · 🔥 {cal:.0f} ккал")
if avg_hr:
lines.append(f" ❤️ ср. {avg_hr} bpm", )
if max_hr and max_hr != avg_hr:
lines[-1] = lines[-1] + f" · макс {max_hr} bpm"
lines.append("")
lines.append("<i>Последние тренировки из Xiaomi Health.</i>")
return "\n".join(lines)
def workouts_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
[[InlineKeyboardButton("⬅️ Тренды", callback_data="menu:trends")]]
)
# ---------------------------------------------------------------------------
# Service menu
# ---------------------------------------------------------------------------
def db_total_records() -> int:
if not health_db_exists():
return 0
conn = health_conn()
tables = ["steps_daily", "sleep_daily", "sleep_stages", "heart_rate", "blood_oxygen", "stress", "calories_daily", "weight", "workouts"]
total = 0
try:
for table in tables:
exists = conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
).fetchone()
if exists:
total += conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
except Exception:
pass
finally:
conn.close()
return total
def more_text() -> str:
status = read_status_file()
last_sync_epoch = status.get("last_sync")
last_sync_str = format_relative_time(last_sync_epoch) if last_sync_epoch else "н/д"
interval_min = int(SETTINGS.sync_interval) // 60
db_records = db_total_records()
return (
"⚙️ <b>Сервис</b>\n\n"
f"Устройство <b>Mi Band</b>\n"
f"Последний синк <b>{last_sync_str}</b>\n"
f"Интервал <b>{interval_min} мин</b>\n"
f"Записей в БД <b>{db_records:,}</b>".replace(",", " ")
)
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 = ["🧰 <b>Статус базы данных</b>", ""]
if not health_db_exists():
return "🧰 <b>Статус базы данных</b>\n\nБаза пока не создана."
conn = health_conn()
try:
tables = ["steps_daily", "sleep_daily", "sleep_stages", "heart_rate", "blood_oxygen",
"stress", "calories_daily", "weight", "workouts"]
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}: <b>{count:,}</b> строк".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"Путь: <code>{esc(DB_PATH)}</code>",
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 (
"🔐 <b>Авторизация Xiaomi</b>\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,
"🔐 <b>Авторизация Xiaomi</b>\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,
"🔐 <b>Авторизация Xiaomi</b>\n\nГотовлю ссылку входа…",
None,
)
auth = XiaomiAuth()
async def qr_callback(qr_image_url: str, login_url: str) -> None:
await update_menu(
update,
context,
"🔐 <b>Авторизация Xiaomi</b>\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,
"✅ <b>Авторизация Xiaomi</b>\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"⚠️ <b>Авторизация Xiaomi</b>\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,
"✅ <b>Авторизация Xiaomi</b>\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"⚠️ <b>Первая синхронизация</b>\n\nАвторизация сохранена, но данные не обновились.{detail}",
auth_retry_keyboard(),
)
# ---------------------------------------------------------------------------
# Menu keyboards
# ---------------------------------------------------------------------------
def main_keyboard() -> InlineKeyboardMarkup:
is_en = os.getenv("BOT_LANG") == "en"
if is_en:
return InlineKeyboardMarkup(
[
[
InlineKeyboardButton("😴 Sleep", callback_data="menu:sleep"),
InlineKeyboardButton("📊 Weekly", callback_data="menu:trends"),
],
[
InlineKeyboardButton("📅 History", callback_data="menu:history"),
InlineKeyboardButton("⚙️ Settings", callback_data="menu:more"),
],
]
)
return InlineKeyboardMarkup(
[
[
InlineKeyboardButton("😴 Сон", callback_data="menu:sleep"),
InlineKeyboardButton("📊 За неделю", callback_data="menu:trends"),
],
[
InlineKeyboardButton("📅 История", callback_data="menu:history"),
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,
"🔄 <b>Синхронизация</b>\n\nМодуль синхронизации не найден.",
back_keyboard("menu:more"),
)
return
if SYNC_LOCK.locked():
await update_menu(
update,
context,
"🔄 <b>Синхронизация</b>\n\nУже идёт обновление. Подожди завершения.",
back_keyboard("menu:more"),
)
return
await update_menu(
update,
context,
"🔄 <b>Синхронизация</b>\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"🔄 <b>Синхронизация</b>\n\nОшибка запуска: {esc(e)}",
back_keyboard("menu:more"),
)
return
if result.success:
await update_menu(
update,
context,
"✅ <b>Синхронизация</b>\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"⚠️ <b>Синхронизация</b>\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,
"💾 <b>Экспорт</b>\n\nБаза данных пока не создана.",
back_keyboard("menu:more"),
)
return
await update_menu(update, context, "💾 <b>Экспорт</b>\n\nСобираю ZIP…", None)
try:
export_file = zip_export()
if export_file.getbuffer().nbytes == 0:
await update_menu(
update,
context,
"💾 <b>Экспорт</b>\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,
"✅ <b>Экспорт</b>\n\nZIP с CSV-таблицами отправлен выше.",
back_keyboard("menu:more"),
)
except Exception as e:
logger.exception("Export failed")
await update_menu(
update,
context,
f"⚠️ <b>Экспорт</b>\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
text = update.message.text if update.message else ""
await safe_delete(update.message)
if not has_xiaomi_token():
await show_onboarding(update, context)
return
if "Сон" in text or "Sleep" in text:
await update_menu(update, context, latest_sleep_text(), back_keyboard())
elif "За неделю" in text or "Weekly" in text:
await update_menu(update, context, period_text(7), trends_keyboard(7))
elif "История" in text or "History" in text:
await show_history(update, context, 7)
elif "Настройки" in text or "Settings" in text:
await update_menu(update, context, more_text(), more_keyboard())
else:
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(),
back_keyboard(),
)
elif data == "menu:trends":
await update_menu(update, context, period_text(7), trends_keyboard(7))
elif data.startswith("period:"):
days = 3650 if data == "period:all" else 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"📊 <b>День</b>\n\nНе удалось открыть: {esc(e)}",
back_keyboard("menu:history"),
)
elif data == "menu:workouts":
await update_menu(update, context, workouts_text(), workouts_keyboard())
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.telegram_allowed_user_id
if ALLOWED_USER_ID is not None:
DB_PATH = str(SETTINGS.user_db_path(ALLOWED_USER_ID))
print(f"Запуск бота для пользователя ID {ALLOWED_USER_ID}...")
storage.init_health_db(Path(DB_PATH))
else:
DB_PATH = str(SETTINGS.db_path)
print("Бот запущен. Отправьте /start в Telegram чтобы привязать аккаунт.")
storage.init_health_db(Path(DB_PATH))
init_state_db()
app = (
Application.builder()
.token(BOT_TOKEN)
.post_init(start_background_tasks)
.post_shutdown(stop_background_tasks)
.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()