#!/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"🚶 {steps_count:,} · {dist_km:.1f} км · {cals:.0f} ккал".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}{format_minutes(total_sleep)}") else: lines.append("😴 Сон н/д") lines.append("") # 3. Пульс, Кислород, Стресс metrics_parts = [] if hr: metrics_parts.append(f"❤️ {int(hr['value'])}") if spo2: metrics_parts.append(f"🩸 {float(spo2['spo2']):.0f}%") if stress: metrics_parts.append(f"🧘 {int(stress['value'])}") 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"🚶 Шаги: {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"🧍 Активность: разминки: {stand_hours}ч · интенсивность: {intensity_min} мин") lines.append(f"🔥 Энергия: всего: {total_cal:.0f} ккал (активные: {active_cal:.0f} ккал)") elif steps: cals = float(steps['calories']) lines.append(f"🔥 Энергия: {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"😴 Сон: {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"❤️ Пульс: ср. {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"🩸 Кислород: ср. {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"🧘 Стресс: ср. {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"⚖️ Вес: {w_kg:.1f} кг{bmi_part}{fat_part}") lines.append("\n\n".join(metrics_parts)) # Тренировки workouts = data.get("workouts") if workouts: lines.append("") lines.append("🏋️ Тренировки:") 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"• {esc(sport)}{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"📅 Последние {days} дней", "", ] 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} {date_formatted} {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 "😴 Ночной сон\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"😴 Ночной сон · {date_formatted}", "", f"Длительность {format_minutes(total_sleep)}", f"Качество {score} / 100" if score else "Качество н/д", f"Постель {start_str} — {end_str}", f"Пульс покоя {rest_hr_str}", "", f"Глубокий {deep_bar} {format_minutes(deep)}", f"Лёгкий {light_bar} {format_minutes(light)}", ] if rem: lines.append(f"REM {rem_bar} {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"📊 Тренды · {period_label} · {date_range}", "", f"🚶 Шаги всего {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("🧍 Активность ср.") 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("😴 Сон среднее " + (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"❤️ Пульс ср. {hr_str}") spo2_str = "н/д" if spo2 and spo2["count"]: spo2_str = f"{float(spo2['avg_value']):.1f}% · мин. {int(spo2['min_value'])}%" lines.append(f"🩸 SpO2 ср. {spo2_str}") # Стресс lines.append(f"🧘 Стресс ср. {stress_str}") # Вес if weight_str: lines.append(f"⚖️ Вес (последний) {weight_str}") 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("· Все время ·" 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 = ["🏋️ Тренировки", ""] 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"🏋️ {esc(sport)}") 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("Последние тренировки из Xiaomi Health.") 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 ( "⚙️ Сервис\n\n" f"Устройство Mi Band\n" f"Последний синк {last_sync_str}\n" f"Интервал {interval_min} мин\n" f"Записей в БД {db_records:,}".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 = ["🧰 Статус базы данных", ""] if not health_db_exists(): return "🧰 Статус базы данных\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}: {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: 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, "🔄 Синхронизация\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 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"📊 День\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()