mirror of
https://github.com/alexgetmancom/miband-bot.git
synced 2026-07-23 22:16:06 +03:00
feat: add one-click installers and Windows launcher
This commit is contained in:
@@ -35,10 +35,12 @@ from miband_tracker.secure_files import save_auth_token
|
||||
from miband_tracker.sync import run_sync
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
# 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.INFO,
|
||||
level=logging.WARNING,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
@@ -46,6 +48,13 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1499,10 +1508,11 @@ def main() -> None:
|
||||
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))
|
||||
logger.info("Стартую fitness-bot для пользователя: %s", ALLOWED_USER_ID)
|
||||
print(f"Запуск бота для пользователя ID {ALLOWED_USER_ID}...")
|
||||
else:
|
||||
DB_PATH = str(SETTINGS.db_path)
|
||||
logger.info("Стартую fitness-bot в режиме ожидания привязки владельца (первое входящее сообщение привяжет бота)...")
|
||||
print("Бот запущен. Отправьте /start в Telegram чтобы привязать аккаунт.")
|
||||
|
||||
|
||||
init_state_db()
|
||||
app = Application.builder().token(BOT_TOKEN).build()
|
||||
|
||||
@@ -8,6 +8,28 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_local_env(filename: str = "secrets.env") -> None:
|
||||
"""Load KEY=VALUE pairs from a local .env file into os.environ.
|
||||
|
||||
Only sets variables that are NOT already present in the environment
|
||||
(explicit env always wins). Skips blank lines and comments (#).
|
||||
"""
|
||||
env_file = Path(filename)
|
||||
if not env_file.exists():
|
||||
return
|
||||
try:
|
||||
for raw_line in env_file.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value.strip()
|
||||
except Exception:
|
||||
pass # Never crash on env-file read failure
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""Invalid runtime configuration."""
|
||||
|
||||
@@ -44,7 +66,18 @@ class Settings:
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, *, require_bot: bool = False) -> Settings:
|
||||
data_dir = Path(os.environ.get("DATA_DIR", "/opt/miband-tracker/data"))
|
||||
# Load local secrets.env (if present) before reading env vars.
|
||||
# This makes Python the single source of truth on all platforms.
|
||||
_load_local_env()
|
||||
# Auto-detect local vs Docker mode: if DATA_DIR is not set, use ./data
|
||||
# when running locally (secrets.env present or ./data already exists),
|
||||
# otherwise fall back to the Docker default /opt/miband-tracker/data.
|
||||
_default_data = (
|
||||
"./data"
|
||||
if (Path("secrets.env").exists() or Path("data").is_dir())
|
||||
else "/opt/miband-tracker/data"
|
||||
)
|
||||
data_dir = Path(os.environ.get("DATA_DIR", _default_data))
|
||||
allowed_user_id = parse_single_user_id(
|
||||
os.environ.get("TELEGRAM_ALLOWED_USER_ID", ""), required=False
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ def exclusive_file_lock(path: Path) -> Iterator[None]:
|
||||
lock_file.seek(0)
|
||||
# Блокируем первые 64 байта файла без ожидания (non-blocking)
|
||||
msvcrt.locking(fd, msvcrt.LK_NBLCK, 64)
|
||||
except (OSError, IOError) as exc:
|
||||
except OSError as exc:
|
||||
raise LockUnavailable(f"Lock is already held: {path}") from exc
|
||||
else:
|
||||
# Резервный вариант, если блокировки недоступны на платформе
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Alexey
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import TextIO
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if reconfigure is None:
|
||||
continue
|
||||
try:
|
||||
reconfigure(encoding="utf-8", errors="backslashreplace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def safe_print(text: str, *, file: TextIO | None = None, flush: bool = False) -> None:
|
||||
stream = file or sys.stdout
|
||||
try:
|
||||
print(text, file=stream, flush=flush)
|
||||
return
|
||||
except UnicodeEncodeError:
|
||||
encoding = getattr(stream, "encoding", None) or "utf-8"
|
||||
fallback = text.encode(encoding, errors="backslashreplace").decode(encoding, errors="replace")
|
||||
print(fallback, file=stream, flush=flush)
|
||||
@@ -17,6 +17,7 @@ from .config import ConfigError, Settings
|
||||
from .fds import download_and_decrypt_sleep_details, parse_all_day_sleep_bytes
|
||||
from .lock import LockUnavailable, exclusive_file_lock
|
||||
from .secure_files import save_auth_token, write_json_atomic, write_secret_json
|
||||
from .stdio import safe_print
|
||||
from .storage import init_health_db, sqlite_conn
|
||||
|
||||
|
||||
@@ -34,7 +35,7 @@ class SyncResult:
|
||||
|
||||
def log(message: str) -> None:
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{now}] {message}", flush=True)
|
||||
safe_print(f"[{now}] {message}", flush=True)
|
||||
|
||||
|
||||
def format_epoch(epoch: int | float | None) -> str | None:
|
||||
@@ -364,24 +365,24 @@ def _write_status_file(status_path: Path, latest_steps, latest_heart_rate, lates
|
||||
async def daemon_main(settings: Settings | None = None) -> int:
|
||||
settings = settings or Settings.from_env()
|
||||
if settings.sync_interval <= 0:
|
||||
log("Running in one-shot mode.")
|
||||
result = await run_sync(settings=settings)
|
||||
return 0 if result.success else 1
|
||||
|
||||
log(f"Running in daemon mode. Sync interval: {settings.sync_interval} seconds.")
|
||||
_waiting_logged = False
|
||||
while True:
|
||||
try:
|
||||
current_settings = Settings.from_env()
|
||||
if current_settings.telegram_allowed_user_id is None:
|
||||
log("Waiting for allowed user ID to be registered via Telegram bot...")
|
||||
if not _waiting_logged:
|
||||
log("Синхронизатор ожидает привязки аккаунта через Telegram (/start)...")
|
||||
_waiting_logged = True
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
_waiting_logged = False # Reset so we log again if user unregisters
|
||||
await run_sync(settings=current_settings)
|
||||
except Exception as exc:
|
||||
log(f"Unhandled error in main loop: {exc}")
|
||||
|
||||
# Load settings again to catch any runtime changes in sync interval
|
||||
interval = Settings.from_env().sync_interval
|
||||
log(f"Sleeping for {interval} seconds...")
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
Reference in New Issue
Block a user