From 99a22e11bd045b18375f89e3439c120b747573bc Mon Sep 17 00:00:00 2001
From: Alex Getman <216438466+alexgetmancom@users.noreply.github.com>
Date: Wed, 12 Aug 2026 13:48:10 +0300
Subject: [PATCH] fix sync reliability and runtime handling
---
README.md | 2 +-
README.ru.md | 8 +-
src/bot/app.ts | 145 +++++++++++++++++++++--------------
src/bot/formatting.ts | 24 ++++--
src/config.ts | 11 ++-
src/index.ts | 12 ++-
src/storage/secure-files.ts | 18 +++--
src/sync.ts | 149 +++++++++++++++++++++++++-----------
src/xiaomi/client.ts | 25 ++++--
src/xiaomi/fds.ts | 4 +-
tests/config.test.ts | 4 +
tests/formatting.test.ts | 8 +-
tests/secure-files.test.ts | 11 ++-
13 files changed, 289 insertions(+), 132 deletions(-)
diff --git a/README.md b/README.md
index 9b40dcd..127c7e9 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ SQLite databases, and exports private.
bun install
bun run check
bun run dev
-`
+```
The service exposes `/healthz` and `/readyz` on port `8080`.
diff --git a/README.ru.md b/README.ru.md
index 35926e5..f6dda11 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -20,24 +20,24 @@
## Быстрый запуск
-`
+```
cp .env.example secrets.env
# Укажите TELEGRAM_BOT_TOKEN в secrets.env
bun install
bun run check
docker compose up -d --build
-`
+```
Рабочие данные находятся в `./data`. Файлы `secrets.env`, `data/`, токены Xiaomi,
SQLite-базы и экспорты должны оставаться приватными.
## Разработка
-`
+```
bun install
bun run check
bun run dev
-`
+```
Сервис открывает `/healthz` и `/readyz` на порту `8080`.
diff --git a/src/bot/app.ts b/src/bot/app.ts
index b96578b..11faeae 100644
--- a/src/bot/app.ts
+++ b/src/bot/app.ts
@@ -101,7 +101,7 @@ function mainMenuText(config: AppConfig, uid: number): string {
lines.push("");
if (sleep) {
lines.push(
- `😴 ${epoch(rowNumber(sleep, "start_time"), false, locale)}→${epoch(rowNumber(sleep, "end_time"), false, locale)} · ${minutes(sleepTotal(sleep), locale)}`,
+ `😴 ${epoch(rowNumber(sleep, "start_time"), false, locale, config.TZ)}→${epoch(rowNumber(sleep, "end_time"), false, locale, config.TZ)} · ${minutes(sleepTotal(sleep), locale)}`,
);
} else lines.push(t(locale, "main.no-sleep"));
lines.push("");
@@ -128,7 +128,7 @@ function workoutsText(config: AppConfig, uid: number): string {
"",
...rows.map(
(row) =>
- `• ${workoutType(row.sport_type, locale)} · ${epoch(rowNumber(row, "start_time"), false, locale)} · ${Math.round(rowNumber(row, "duration_sec") / 60)} ${t(locale, "common.minutes")} · ${Math.round(rowNumber(row, "calories"))} ${t(locale, "common.kcal")} · ${rowNumber(row, "avg_hr")} bpm`,
+ `• ${workoutType(row.sport_type, locale)} · ${epoch(rowNumber(row, "start_time"), false, locale, config.TZ)} · ${Math.round(rowNumber(row, "duration_sec") / 60)} ${t(locale, "common.minutes")} · ${Math.round(rowNumber(row, "calories"))} ${t(locale, "common.kcal")} · ${rowNumber(row, "avg_hr")} bpm`,
),
].join("\n");
}
@@ -162,40 +162,68 @@ function statusText(config: AppConfig, uid: number): string {
return lines.join("\n");
}
-function localDay(offset = 0): string {
- const value = new Date(Date.now() + offset * 86_400_000);
- const parts = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Moscow" }).formatToParts(value);
- const year = parts.find((part) => part.type === "year")?.value ?? "1970";
- const month = parts.find((part) => part.type === "month")?.value ?? "01";
- const day = parts.find((part) => part.type === "day")?.value ?? "01";
- return `${year}-${month}-${day}`;
+function zonedDateParts(value: number, timeZone: string): Record {
+ return Object.fromEntries(
+ new Intl.DateTimeFormat("en-US", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ weekday: "short",
+ hour12: false,
+ })
+ .formatToParts(value)
+ .filter((part) => part.type !== "literal")
+ .map((part) => [part.type, part.value]),
+ );
}
-function dayEpochBounds(day: string): [number, number] {
- const start = Math.floor(new Date(`${day}T00:00:00+03:00`).getTime() / 1000);
- return [start, start + 86_400];
+function localDay(timeZone: string, offset = 0): string {
+ const parts = zonedDateParts(Date.now(), timeZone);
+ const value = new Date(Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day) + offset));
+ return `${String(value.getUTCFullYear()).padStart(4, "0")}-${String(value.getUTCMonth() + 1).padStart(2, "0")}-${String(value.getUTCDate()).padStart(2, "0")}`;
}
-function formatWeekday(day: string, locale: Locale): string {
+function timeZoneOffsetMinutes(value: number, timeZone: string): number {
+ const zone = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "longOffset" })
+ .formatToParts(value)
+ .find((part) => part.type === "timeZoneName")?.value;
+ const match = zone?.match(/^GMT([+-])(\d{2})(?::(\d{2}))?$/);
+ if (!match) return 0;
+ const minutes = Number(match[2]) * 60 + Number(match[3] ?? 0);
+ return match[1] === "+" ? minutes : -minutes;
+}
+
+function zonedMidnight(day: string, timeZone: string): number {
+ const naive = Date.parse(`${day}T00:00:00Z`);
+ const candidate = naive - timeZoneOffsetMinutes(naive, timeZone) * 60_000;
+ const corrected = naive - timeZoneOffsetMinutes(candidate, timeZone) * 60_000;
+ return Math.floor(corrected / 1000);
+}
+
+function dayEpochBounds(day: string, timeZone: string): [number, number] {
+ const nextDay = new Date(`${day}T12:00:00Z`);
+ nextDay.setUTCDate(nextDay.getUTCDate() + 1);
+ const start = zonedMidnight(day, timeZone);
+ return [start, zonedMidnight(nextDay.toISOString().slice(0, 10), timeZone)];
+}
+
+function formatWeekday(day: string, locale: Locale, timeZone: string): string {
const value = new Date(`${day}T12:00:00Z`);
- return new Intl.DateTimeFormat(numberLocale(locale), { weekday: "short", timeZone: "Europe/Moscow" })
- .format(value)
- .replace(".", "");
+ return new Intl.DateTimeFormat(numberLocale(locale), { weekday: "short", timeZone }).format(value).replace(".", "");
}
-function averageBedtime(rows: Record[]): number | null {
+function averageBedtime(rows: Record[], timeZone: string): number | null {
const offsets = rows
.map((row) => rowNumber(row, "start_time"))
.filter(Boolean)
.map((time) => {
const hour = Number(
- new Intl.DateTimeFormat("en-US", { hour: "numeric", hour12: false, timeZone: "Europe/Moscow" }).format(
- time * 1000,
- ),
- );
- const minute = Number(
- new Intl.DateTimeFormat("en-US", { minute: "numeric", timeZone: "Europe/Moscow" }).format(time * 1000),
+ new Intl.DateTimeFormat("en-US", { hour: "numeric", hour12: false, timeZone }).format(time * 1000),
);
+ const minute = Number(new Intl.DateTimeFormat("en-US", { minute: "numeric", timeZone }).format(time * 1000));
const offset = hour * 60 + minute;
return offset > 720 ? offset - 1440 : offset;
});
@@ -225,7 +253,7 @@ function metricStats(
}
function daySummary(config: AppConfig, uid: number, day: string): Record {
- const [start, end] = dayEpochBounds(day);
+ const [start, end] = dayEpochBounds(day, config.TZ);
const sleep = dbRow(
config,
uid,
@@ -272,10 +300,10 @@ function availableDays(config: AppConfig, uid: number, limit: number): string[]
}
function periodSummary(config: AppConfig, uid: number, days: number): Record {
- const end = localDay();
- const start = localDay(-(Math.max(1, days) - 1));
- const [startEpoch] = dayEpochBounds(start);
- const [, endEpoch] = dayEpochBounds(end);
+ const end = localDay(config.TZ);
+ const start = localDay(config.TZ, -(Math.max(1, days) - 1));
+ const [startEpoch] = dayEpochBounds(start, config.TZ);
+ const [, endEpoch] = dayEpochBounds(end, config.TZ);
let weight = dbRows(
config,
uid,
@@ -349,7 +377,7 @@ function sleepText(config: AppConfig, uid: number): string {
const dateLabel = new Intl.DateTimeFormat(numberLocale(locale), {
day: "numeric",
month: "long",
- timeZone: "Europe/Moscow",
+ timeZone: config.TZ,
}).format(date);
const deep = rowNumber(sleep, "deep_sleep_min");
const light = rowNumber(sleep, "light_sleep_min");
@@ -379,7 +407,7 @@ function sleepText(config: AppConfig, uid: number): string {
"",
`${t(locale, "sleep.duration")} ${minutes(sleepTotal(sleep), locale)}`,
`${t(locale, "sleep.quality")} ${rowNumber(sleep, "sleep_score") || t(locale, "common.na")}${rowNumber(sleep, "sleep_score") ? " / 100" : ""}`,
- `${t(locale, "sleep.bed")} ${epoch(start, false, locale)} — ${epoch(end, false, locale)}`,
+ `${t(locale, "sleep.bed")} ${epoch(start, false, locale, config.TZ)} — ${epoch(end, false, locale, config.TZ)}`,
`${t(locale, "sleep.resting-heart-rate")} ${rest && rowNumber(rest, "min_hr") ? `${rowNumber(rest, "min_hr")} bpm` : t(locale, "common.na")}`,
"",
`${t(locale, "sleep.deep")} ${bar(deep)} ${minutes(deep, locale)}`,
@@ -402,7 +430,7 @@ function dayText(config: AppConfig, uid: number, day: string): string {
const calories = data.calories as Record | null;
const weight = data.weight as Record | null;
const workouts = (data.workouts as Record[]) ?? [];
- const dayLabel = relativeDay(day, locale);
+ const dayLabel = relativeDay(day, locale, config.TZ);
const lines = [t(locale, "day.details", { day: esc(day), label: esc(dayLabel) }), ""];
if (steps)
lines.push(
@@ -419,14 +447,17 @@ function dayText(config: AppConfig, uid: number, day: string): string {
lines.push(
t(locale, "day.energy", {
total: rowNumber(calories, "total_cal").toFixed(0),
- active: rowNumber(calories, "active_cal").toFixed(0),
+ active:
+ calories.active_cal === null || calories.active_cal === undefined
+ ? t(locale, "common.na")
+ : rowNumber(calories, "active_cal").toFixed(0),
}),
);
} else if (steps) lines.push(t(locale, "day.energy-simple", { calories: rowNumber(steps, "calories").toFixed(0) }));
lines.push("");
if (sleep)
lines.push(
- `${t(locale, "day.sleep", { total: minutes(sleepTotal(sleep), locale), deep: minutes(rowNumber(sleep, "deep_sleep_min"), locale), light: minutes(rowNumber(sleep, "light_sleep_min"), locale) })}${rowNumber(sleep, "sleep_score") ? ` · ${rowNumber(sleep, "sleep_score")}/100` : ""} · ${epoch(rowNumber(sleep, "start_time"), false, locale)}→${epoch(rowNumber(sleep, "end_time"), false, locale)}`,
+ `${t(locale, "day.sleep", { total: minutes(sleepTotal(sleep), locale), deep: minutes(rowNumber(sleep, "deep_sleep_min"), locale), light: minutes(rowNumber(sleep, "light_sleep_min"), locale) })}${rowNumber(sleep, "sleep_score") ? ` · ${rowNumber(sleep, "sleep_score")}/100` : ""} · ${epoch(rowNumber(sleep, "start_time"), false, locale, config.TZ)}→${epoch(rowNumber(sleep, "end_time"), false, locale, config.TZ)}`,
);
else lines.push(t(locale, "day.no-sleep"));
lines.push("");
@@ -465,7 +496,7 @@ function dayText(config: AppConfig, uid: number, day: string): string {
lines.push("", t(locale, "day.training"));
for (const workout of workouts)
lines.push(
- `• ${esc(workoutType(rowString(workout, "sport_type"), locale))} ${epoch(rowNumber(workout, "start_time"), false, locale)} (${Math.floor(rowNumber(workout, "duration_sec") / 60)}:${String(Math.floor(rowNumber(workout, "duration_sec") % 60)).padStart(2, "0")} · 🔥 ${rowNumber(workout, "calories").toFixed(0)} ${t(locale, "common.kcal")})`,
+ `• ${esc(workoutType(rowString(workout, "sport_type"), locale))} ${epoch(rowNumber(workout, "start_time"), false, locale, config.TZ)} (${Math.floor(rowNumber(workout, "duration_sec") / 60)}:${String(Math.floor(rowNumber(workout, "duration_sec") % 60)).padStart(2, "0")} · 🔥 ${rowNumber(workout, "calories").toFixed(0)} ${t(locale, "common.kcal")})`,
);
}
return lines.join("\n");
@@ -473,8 +504,8 @@ function dayText(config: AppConfig, uid: number, day: string): string {
function historyText(config: AppConfig, uid: number, days = 7): string {
const locale = localeOf(config, uid);
- const end = localDay();
- const start = localDay(-(days - 1));
+ const end = localDay(config.TZ);
+ const start = localDay(config.TZ, -(days - 1));
const steps = dbRows(config, uid, "SELECT date,total_steps FROM steps_daily WHERE date BETWEEN ? AND ?", [
start,
end,
@@ -502,7 +533,7 @@ function historyText(config: AppConfig, uid: number, days = 7): string {
const label = new Intl.DateTimeFormat(numberLocale(locale), {
day: "2-digit",
month: "2-digit",
- timeZone: "Europe/Moscow",
+ timeZone: config.TZ,
}).format(date);
return `${dayEmoji(step, sleepRow)} ${label} ${step ? `${rowNumber(step, "total_steps").toLocaleString(numberLocale(locale))} ${t(locale, "history.steps")}` : t(locale, "history.no-steps")} · ${sleepRow ? minutes(sleepTotal(sleepRow), locale) : t(locale, "history.no-sleep")}`;
}),
@@ -520,9 +551,9 @@ function weeklyText(config: AppConfig, uid: number): string {
values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
const avgSteps = averages(steps.map((row) => rowNumber(row, "total_steps")));
const avgSleep = averages(sleep.map((row) => sleepTotal(row)));
- const avgBed = formatBedtime(averageBedtime(sleep), locale);
- const previousStart = localDay(-13);
- const previousEnd = localDay(-7);
+ const avgBed = formatBedtime(averageBedtime(sleep, config.TZ), locale);
+ const previousStart = localDay(config.TZ, -13);
+ const previousEnd = localDay(config.TZ, -7);
const previousSteps = dbRows(config, uid, "SELECT total_steps FROM steps_daily WHERE date BETWEEN ? AND ?", [
previousStart,
previousEnd,
@@ -560,10 +591,10 @@ function weeklyText(config: AppConfig, uid: number): string {
"",
t(locale, "weekly.records", {
steps: recordSteps
- ? `${rowNumber(recordSteps, "total_steps")} (${formatWeekday(rowString(recordSteps, "date"), locale)})`
+ ? `${rowNumber(recordSteps, "total_steps")} (${formatWeekday(rowString(recordSteps, "date"), locale, config.TZ)})`
: t(locale, "common.na"),
sleep: recordSleep
- ? `${minutes(sleepTotal(recordSleep), locale)} (${formatWeekday(rowString(recordSleep, "date"), locale)})`
+ ? `${minutes(sleepTotal(recordSleep), locale)} (${formatWeekday(rowString(recordSleep, "date"), locale, config.TZ)})`
: t(locale, "common.na"),
}),
].join("\n");
@@ -646,8 +677,8 @@ function familyText(config: AppConfig, uid: number): string {
const [first, second] = config.allowedUserIds;
if (first === undefined || second === undefined) return t(locale, "family.no-data");
const stats = (uid: number) => {
- const start = localDay(-6);
- const end = localDay();
+ const start = localDay(config.TZ, -6);
+ const end = localDay(config.TZ);
const steps = dbRows(config, uid, "SELECT total_steps,distance_m FROM steps_daily WHERE date BETWEEN ? AND ?", [
start,
end,
@@ -667,7 +698,7 @@ function familyText(config: AppConfig, uid: number): string {
avgSleep: sleep.length
? Math.round(sleep.reduce((sum, row) => sum + rowNumber(row, "total_duration_min"), 0) / sleep.length)
: 0,
- bedtime: averageBedtime(sleep),
+ bedtime: averageBedtime(sleep, config.TZ),
};
};
const a = stats(first);
@@ -717,8 +748,8 @@ function versusText(config: AppConfig, uid: number, days: number): string {
if (first === undefined || second === undefined) return t(locale, "versus.no-data");
const names = [USER_NAMES[first] ?? `User ${first}`, USER_NAMES[second] ?? `User ${second}`];
const data = (uid: number) => {
- const end = localDay();
- const start = localDay(-(days - 1));
+ const end = localDay(config.TZ);
+ const start = localDay(config.TZ, -(days - 1));
const steps = dbRows(config, uid, "SELECT total_steps FROM steps_daily WHERE date BETWEEN ? AND ?", [
start,
end,
@@ -734,7 +765,7 @@ function versusText(config: AppConfig, uid: number, days: number): string {
sleep: sleep.length
? Math.round(sleep.reduce((sum, row) => sum + rowNumber(row, "total_duration_min"), 0) / sleep.length)
: 0,
- bedtime: averageBedtime(sleep),
+ bedtime: averageBedtime(sleep, config.TZ),
};
};
const a = data(first);
@@ -898,7 +929,7 @@ function historyKb(config: AppConfig, uid: number, days = 7, locale = localeOf(c
new Intl.DateTimeFormat(numberLocale(locale), {
day: "numeric",
month: "short",
- timeZone: "Europe/Moscow",
+ timeZone: config.TZ,
}).format(new Date(`${day}T12:00:00Z`)),
`day:${day}`,
] as [string, string],
@@ -908,7 +939,7 @@ function historyKb(config: AppConfig, uid: number, days = 7, locale = localeOf(c
return keyboard(buttons);
}
-function dayKb(day: string, locale: Locale): InlineKeyboard {
+function dayKb(day: string, locale: Locale, timeZone: string): InlineKeyboard {
const previous = new Date(`${day}T12:00:00Z`);
previous.setUTCDate(previous.getUTCDate() - 1);
const next = new Date(`${day}T12:00:00Z`);
@@ -919,8 +950,8 @@ function dayKb(day: string, locale: Locale): InlineKeyboard {
[
[`◀️ ${iso(previous)}`, `day:${iso(previous)}`],
[
- nextDay > localDay() ? t(locale, "menu.home") : `${nextDay} ▶️`,
- nextDay > localDay() ? "menu:main" : `day:${nextDay}`,
+ nextDay > localDay(timeZone) ? t(locale, "menu.home") : `${nextDay} ▶️`,
+ nextDay > localDay(timeZone) ? "menu:main" : `day:${nextDay}`,
],
],
[[t(locale, "menu.calendar"), "menu:history"]],
@@ -1135,7 +1166,7 @@ export function createBot(config: AppConfig): Bot {
}
if (data.startsWith("day:")) {
const day = data.slice("day:".length);
- return showMenu(context, config, dayText(config, uid, day), dayKb(day, locale));
+ return showMenu(context, config, dayText(config, uid, day), dayKb(day, locale, config.TZ));
}
if (data === "menu:more") return showMenu(context, config, moreText(config, uid), moreKb(locale));
if (data === "menu:language")
@@ -1217,9 +1248,9 @@ export function startBotTasks(bot: Bot, config: AppConfig): TaskHand
const seenStatus = new Map();
const run = async (): Promise => {
if (stopped) return;
- const today = new Date();
- const weeklyDate = today.toISOString().slice(0, 10);
- if (today.getDay() === 0 && today.getHours() === 21 && today.getMinutes() === 0 && lastWeeklyDate !== weeklyDate) {
+ const now = zonedDateParts(Date.now(), config.TZ);
+ const weeklyDate = `${now.year}-${now.month}-${now.day}`;
+ if (now.weekday === "Sun" && Number(now.hour) === 21 && Number(now.minute) === 0 && lastWeeklyDate !== weeklyDate) {
lastWeeklyDate = weeklyDate;
for (const uid of config.allowedUserIds) {
try {
@@ -1239,11 +1270,11 @@ export function startBotTasks(bot: Bot, config: AppConfig): TaskHand
try {
const modified = statSync(canonicalUserStatusPath(config, uid)).mtimeMs;
if (modified <= (seenStatus.get(uid) ?? 0)) continue;
- seenStatus.set(uid, modified);
await bot.api.editMessageText(uid, menuMessageId, mainMenuText(config, uid), {
parse_mode: "HTML",
reply_markup: mainKb(localeOf(config, uid)),
});
+ seenStatus.set(uid, modified);
} catch (error) {
log("debug", "Automatic menu refresh skipped", { userId: uid, error });
}
diff --git a/src/bot/formatting.ts b/src/bot/formatting.ts
index ab4dfc7..e961549 100644
--- a/src/bot/formatting.ts
+++ b/src/bot/formatting.ts
@@ -73,6 +73,7 @@ const SPORT_TYPES: Record> = {
martial_arts: "Artes marciales",
},
};
+const DEFAULT_TIME_ZONE = "Europe/Moscow";
export function esc(value: unknown): string {
return String(value)
@@ -82,12 +83,12 @@ export function esc(value: unknown): string {
.replaceAll('"', """);
}
-export function epoch(value: unknown, withDate = true, locale: Locale = "en"): string {
+export function epoch(value: unknown, withDate = true, locale: Locale = "en", timeZone = DEFAULT_TIME_ZONE): string {
const seconds = Number(value);
if (!seconds) return t(locale, "common.na");
const date = new Date(seconds * 1000);
return new Intl.DateTimeFormat(locale === "ru" ? "ru-RU" : locale === "es" ? "es-ES" : "en-GB", {
- timeZone: "Europe/Moscow",
+ timeZone,
...(withDate ? { dateStyle: "short" } : {}),
timeStyle: "short",
}).format(date);
@@ -120,10 +121,23 @@ export function stepBar(value: unknown, goal = DEFAULT_STEP_GOAL): string {
return `[${"█".repeat(blocks)}${"░".repeat(10 - blocks)}] ${percent}%`;
}
-export function relativeDay(day: string | undefined, locale: Locale = "en"): string {
+function dateKey(value: number, timeZone: string): string {
+ const parts = new Intl.DateTimeFormat("en-CA", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ }).formatToParts(value);
+ const year = parts.find((part) => part.type === "year")?.value ?? "1970";
+ const month = parts.find((part) => part.type === "month")?.value ?? "01";
+ const day = parts.find((part) => part.type === "day")?.value ?? "01";
+ return `${year}-${month}-${day}`;
+}
+
+export function relativeDay(day: string | undefined, locale: Locale = "en", timeZone = DEFAULT_TIME_ZONE): string {
if (!day) return t(locale, "common.day");
- const today = new Date().toISOString().slice(0, 10);
- const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
+ const today = dateKey(Date.now(), timeZone);
+ const yesterday = dateKey(Date.now() - 86_400_000, timeZone);
return day === today ? t(locale, "common.today") : day === yesterday ? t(locale, "common.yesterday") : day;
}
diff --git a/src/config.ts b/src/config.ts
index 2854ed8..d1ce2be 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -15,6 +15,15 @@ const booleanText = z.preprocess((value) => {
return value;
}, z.boolean());
+const timeZoneText = z.string().refine((value) => {
+ try {
+ new Intl.DateTimeFormat("en-US", { timeZone: value }).format();
+ return true;
+ } catch {
+ return false;
+ }
+}, "must be a valid IANA timezone");
+
export class ConfigurationError extends Error {
constructor(message: string) {
super(message);
@@ -75,7 +84,7 @@ const envSchema = z.object({
AUTO_MENU_REFRESH_INTERVAL: z.coerce.number().positive().default(30),
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
BIND_HOST: z.string().min(1).default("127.0.0.1"),
- TZ: z.string().default("Europe/Moscow"),
+ TZ: timeZoneText.default("Europe/Moscow"),
});
export type AppConfig = z.infer & {
diff --git a/src/index.ts b/src/index.ts
index f97d531..59e1769 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -68,9 +68,15 @@ async function main(): Promise {
process.exitCode = 1;
});
} else if (config.BOT_MODE === "webhook" && config.PUBLIC_WEBHOOK_URL && config.TELEGRAM_WEBHOOK_SECRET) {
- await bot.api.setWebhook(`${config.PUBLIC_WEBHOOK_URL}/telegram/webhook`, {
- secret_token: config.TELEGRAM_WEBHOOK_SECRET,
- });
+ try {
+ await bot.api.setWebhook(`${config.PUBLIC_WEBHOOK_URL}/telegram/webhook`, {
+ secret_token: config.TELEGRAM_WEBHOOK_SECRET,
+ });
+ } catch (error) {
+ log("error", "Failed to configure Telegram webhook", { error });
+ await shutdown("TELEGRAM_WEBHOOK_SETUP_FAILED");
+ throw error;
+ }
}
}
log("info", "HTTP server listening", { address: `http://${config.BIND_HOST}:${config.PORT}`, mode: config.BOT_MODE });
diff --git a/src/storage/secure-files.ts b/src/storage/secure-files.ts
index f80a727..d6a3d6a 100644
--- a/src/storage/secure-files.ts
+++ b/src/storage/secure-files.ts
@@ -1,16 +1,17 @@
import {
chmodSync,
- existsSync,
mkdirSync,
readFileSync,
renameSync,
rmdirSync,
+ statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
const SECRET_MODE = 0o600;
+const STALE_LOCK_AGE_MS = 2 * 60 * 60 * 1000;
export function writeTextAtomic(path: string, text: string, mode?: number): void {
mkdirSync(dirname(path), { recursive: true });
@@ -83,13 +84,14 @@ export class LockUnavailable extends Error {
export async function withExclusiveFileLock(path: string, action: () => Promise): Promise {
const lockDir = `${path}.d`;
+ const ownerPath = join(lockDir, "owner");
mkdirSync(dirname(path), { recursive: true });
try {
mkdirSync(lockDir);
} catch {
let stale = false;
try {
- const lockText = readFileSync(path, "utf8");
+ const lockText = readFileSync(ownerPath, "utf8");
const pid = Number(lockText.match(/pid=(\d+)/)?.[1] ?? 0);
if (pid > 0) {
try {
@@ -99,23 +101,27 @@ export async function withExclusiveFileLock(path: string, action: () => Promi
}
}
} catch {
- stale = false;
+ try {
+ stale = Date.now() - statSync(lockDir).mtimeMs > STALE_LOCK_AGE_MS;
+ } catch {
+ stale = false;
+ }
}
if (!stale) throw new LockUnavailable(`Lock is already held: ${path}`);
try {
+ unlinkSync(ownerPath);
rmdirSync(lockDir);
- if (existsSync(path)) unlinkSync(path);
mkdirSync(lockDir);
} catch {
throw new LockUnavailable(`Lock is already held: ${path}`);
}
}
try {
- writeFileSync(path, `pid=${process.pid} time=${Math.floor(Date.now() / 1000)}\n`, "utf8");
+ writeFileSync(ownerPath, `pid=${process.pid} time=${Math.floor(Date.now() / 1000)}\n`, "utf8");
return await action();
} finally {
try {
- unlinkSync(path);
+ unlinkSync(ownerPath);
} catch {
// Best effort cleanup.
}
diff --git a/src/sync.ts b/src/sync.ts
index 0ad76ab..9305a40 100644
--- a/src/sync.ts
+++ b/src/sync.ts
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { type AppConfig, canonicalUserDbPath, canonicalUserStatusPath, tokenPath, userId } from "./config.js";
import { log } from "./logger.js";
import { initHealthDb, withHealthDb } from "./storage/health.js";
+import { getState, migrateDatabase, openDatabase, setState } from "./storage/kv.js";
import {
type AuthToken,
readToken,
@@ -34,8 +35,33 @@ function record(value: unknown): Record {
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {};
}
-function formatEpoch(value: number): string | null {
- return value ? new Date(value * 1000).toISOString().replace("T", " ").slice(0, 19) : null;
+function formatEpoch(value: number, timeZone: string): string | null {
+ if (!value) return null;
+ const parts = new Intl.DateTimeFormat("en-CA", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ }).formatToParts(value * 1000);
+ const part = (type: string, fallback: string) => parts.find((item) => item.type === type)?.value ?? fallback;
+ return `${part("year", "1970")}-${part("month", "01")}-${part("day", "01")} ${part("hour", "00")}:${part("minute", "00")}:${part("second", "00")}`;
+}
+
+function dateInTimeZone(value: number, timeZone: string): string {
+ const parts = new Intl.DateTimeFormat("en-CA", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ }).formatToParts(value * 1000);
+ const year = parts.find((part) => part.type === "year")?.value ?? "1970";
+ const month = parts.find((part) => part.type === "month")?.value ?? "01";
+ const day = parts.find((part) => part.type === "day")?.value ?? "01";
+ return `${year}-${month}-${day}`;
}
function targetRelativeUid(token: AuthToken): number | null {
@@ -101,20 +127,20 @@ async function runSyncLocked(uid: number, config: AppConfig): Promise {
for (const item of steps) {
- const date = new Date(item.time * 1000).toISOString().slice(0, 10);
+ const date = dateInTimeZone(item.time, config.TZ);
database
.query(
"INSERT OR REPLACE INTO steps_daily (date,total_steps,calories,distance_m,last_sync) VALUES (?,?,?,?,?)",
)
.run(date, item.steps, item.calories, item.distance, timestamp());
increment(counters, "steps_daily");
- if (!latestSteps || date >= new Date(latestSteps.time * 1000).toISOString().slice(0, 10)) latestSteps = item;
+ if (!latestSteps || date >= dateInTimeZone(latestSteps.time, config.TZ)) latestSteps = item;
}
});
const sleep = await client.getSleep(relativeUid, config.QUERY_DURATION);
for (const item of sleep) {
- const date = new Date(item.time * 1000).toISOString().slice(0, 10);
+ const date = dateInTimeZone(item.time, config.TZ);
withHealthDb(config, uid, (database) => {
const segments = item.segment_details;
const start = segments.length ? Math.min(...segments.map((segment) => segment.bedtime)) : 0;
@@ -176,7 +202,7 @@ async function runSyncLocked(uid: number, config: AppConfig): Promise {
for (const item of items) {
const value = parseMetricValue(item, field);
- if (!value) continue;
+ if (value === null || value <= 0) continue;
const query =
table === "blood_oxygen"
? "INSERT OR IGNORE INTO blood_oxygen (timestamp,spo2,type) VALUES (?,?,?)"
@@ -243,7 +269,7 @@ async function syncPointMetrics(
}
}
-function parseMetricValue(item: AggregatedDataItem, field: string): number {
+function parseMetricValue(item: AggregatedDataItem, fields: string | readonly string[]): number | null {
const raw = (() => {
try {
return JSON.parse(item.value) as unknown;
@@ -251,9 +277,17 @@ function parseMetricValue(item: AggregatedDataItem, field: string): number {
return item.value;
}
})();
- if (raw && typeof raw === "object" && !Array.isArray(raw))
- return Number((raw as Record)[field] ?? 0);
- return Number(raw);
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
+ const object = raw as Record;
+ for (const field of Array.isArray(fields) ? fields : [fields]) {
+ if (!(field in object)) continue;
+ const value = Number(object[field]);
+ return Number.isFinite(value) ? value : null;
+ }
+ return null;
+ }
+ const value = Number(raw);
+ return Number.isFinite(value) ? value : null;
}
async function syncCalories(
@@ -267,17 +301,21 @@ async function syncCalories(
const end = Math.floor(Date.now() / 1000);
const values = new Map>();
for (const [key, field, valueKey] of [
- ["calories", "total_cal", "calories"],
- ["intensity", "intensity_minutes", "duration"],
- ["valid_stand", "valid_stand_hours", "count"],
+ ["calories", "total_cal", ["calories", "total_cal", "total_calories"]],
+ ["intensity", "intensity_minutes", ["duration"]],
+ ["valid_stand", "valid_stand_hours", ["count"]],
] as const) {
try {
for (const item of await client.getAggregatedData(relativeUid, key, start, end, config.QUERY_DURATION)) {
const value = parseMetricValue(item, valueKey);
- if (!value) continue;
- const date = new Date(item.time * 1000).toISOString().slice(0, 10);
+ if (value === null || value <= 0) continue;
+ const date = dateInTimeZone(item.time, config.TZ);
const entry = values.get(date) ?? {};
entry[field] = field === "total_cal" ? (entry[field] ?? 0) + value : Math.max(entry[field] ?? 0, value);
+ if (field === "total_cal") {
+ const active = parseMetricValue(item, ["active_cal", "active_calories", "activeCalories"]);
+ if (active !== null && active >= 0) entry.active_cal = Math.max(entry.active_cal ?? 0, active);
+ }
values.set(date, entry);
}
} catch (error) {
@@ -288,7 +326,14 @@ async function syncCalories(
for (const [date, value] of values) {
const result = database
.query(
- "INSERT OR REPLACE INTO calories_daily (date,total_cal,active_cal,valid_stand_hours,intensity_minutes,last_sync) VALUES (?,?,?,?,?,?)",
+ `INSERT INTO calories_daily (date,total_cal,active_cal,valid_stand_hours,intensity_minutes,last_sync)
+ VALUES (?,?,?,?,?,?)
+ ON CONFLICT(date) DO UPDATE SET
+ total_cal = COALESCE(excluded.total_cal, calories_daily.total_cal),
+ active_cal = COALESCE(excluded.active_cal, calories_daily.active_cal),
+ valid_stand_hours = COALESCE(excluded.valid_stand_hours, calories_daily.valid_stand_hours),
+ intensity_minutes = COALESCE(excluded.intensity_minutes, calories_daily.intensity_minutes),
+ last_sync = excluded.last_sync`,
)
.run(
date,
@@ -334,9 +379,10 @@ async function syncWorkouts(
counters: Record,
): Promise {
try {
- let watermark = 0;
+ let watermark = readWorkoutWatermark(config, uid);
let hasMore = true;
while (hasMore) {
+ const previousWatermark = watermark;
const response = await client.request("GET", "/app/v1/data/get_sport_records_by_watermark", {
relative_uid: relativeUid,
watermark,
@@ -361,7 +407,8 @@ async function syncWorkouts(
return record(raw);
})();
const recordWatermark = Number(entry.watermark ?? 0);
- watermark = Math.max(watermark, recordWatermark);
+ if (Number.isSafeInteger(recordWatermark) && recordWatermark >= 0)
+ watermark = Math.max(watermark, recordWatermark);
const workoutId = String(entry.sid ?? entry.did ?? "");
const start = Number(value.start_time ?? entry.time ?? 0);
const duration = Number(value.duration ?? 0);
@@ -385,6 +432,11 @@ async function syncWorkouts(
if (resultRow.changes > 0) increment(counters, "workouts");
}
});
+ if (hasMore && watermark <= previousWatermark) {
+ log("warn", "Workout API returned no watermark progress", { userId: uid, watermark });
+ break;
+ }
+ writeWorkoutWatermark(config, uid, watermark);
}
log("debug", "Workout watermark sync completed", { userId: uid });
} catch (error) {
@@ -392,6 +444,32 @@ async function syncWorkouts(
}
}
+function workoutWatermarkKey(uid: number): string {
+ return `workouts.watermark.${uid}`;
+}
+
+function readWorkoutWatermark(config: AppConfig, uid: number): number {
+ const database = openDatabase(config.botStateDbPath);
+ try {
+ migrateDatabase(database);
+ const value = Number(getState(database, workoutWatermarkKey(uid)) ?? 0);
+ return Number.isSafeInteger(value) && value >= 0 ? value : 0;
+ } finally {
+ database.close();
+ }
+}
+
+function writeWorkoutWatermark(config: AppConfig, uid: number, watermark: number): void {
+ if (!Number.isSafeInteger(watermark) || watermark <= 0) return;
+ const database = openDatabase(config.botStateDbPath);
+ try {
+ migrateDatabase(database);
+ setState(database, workoutWatermarkKey(uid), String(watermark));
+ } finally {
+ database.close();
+ }
+}
+
async function syncFds(
client: MiHealthClient,
relativeUid: number,
@@ -438,53 +516,34 @@ function writeStatus(
steps: StepData | undefined,
heartRate: { timestamp: number; value: number } | undefined,
sleep: SleepData | undefined,
+ timeZone: string,
): void {
writeJsonAtomic(path, {
last_sync: timestamp(),
- last_sync_time: formatEpoch(timestamp()),
+ last_sync_time: formatEpoch(timestamp(), timeZone),
today: steps
? {
- date: new Date(steps.time * 1000).toISOString().slice(0, 10),
+ date: dateInTimeZone(steps.time, timeZone),
steps: steps.steps,
calories: steps.calories,
distance_m: steps.distance,
}
: null,
latest_heart_rate: heartRate
- ? { timestamp: heartRate.timestamp, time: formatEpoch(heartRate.timestamp), value: heartRate.value }
+ ? { timestamp: heartRate.timestamp, time: formatEpoch(heartRate.timestamp, timeZone), value: heartRate.value }
: null,
latest_sleep: sleep
? {
- date: new Date(sleep.time * 1000).toISOString().slice(0, 10),
+ date: dateInTimeZone(sleep.time, timeZone),
light_sleep_min: sleep.sleep_light_duration,
deep_sleep_min: sleep.sleep_deep_duration,
rem_sleep_min: sleep.sleep_rem_duration,
awake_min: sleep.sleep_awake_duration,
total_sleep_min: sleep.total_duration || sleep.sleep_light_duration + sleep.sleep_deep_duration,
sleep_score: sleep.sleep_score,
- start_time: formatEpoch(Math.min(...sleep.segment_details.map((segment) => segment.bedtime), 0)),
- end_time: formatEpoch(Math.max(...sleep.segment_details.map((segment) => segment.wake_up_time), 0)),
+ start_time: formatEpoch(Math.min(...sleep.segment_details.map((segment) => segment.bedtime), 0), timeZone),
+ end_time: formatEpoch(Math.max(...sleep.segment_details.map((segment) => segment.wake_up_time), 0), timeZone),
}
: null,
});
}
-
-export async function runSyncDaemon(config: AppConfig, signal: AbortSignal): Promise {
- while (!signal.aborted) {
- const current = (() => {
- try {
- return config.allowedUserIds;
- } catch {
- return [];
- }
- })();
- if (current.length === 0) {
- log("info", "Sync daemon is waiting for Telegram /start binding");
- await Bun.sleep(5000);
- continue;
- }
- for (const uid of current) await runSync(uid, config);
- if (config.SYNC_INTERVAL <= 0) return;
- await Bun.sleep(config.SYNC_INTERVAL * 1000);
- }
-}
diff --git a/src/xiaomi/client.ts b/src/xiaomi/client.ts
index 1711b7a..d2a333a 100644
--- a/src/xiaomi/client.ts
+++ b/src/xiaomi/client.ts
@@ -11,6 +11,7 @@ const SERVICE_LOGIN_URL = "https://account.xiaomi.com/pass/serviceLogin";
const DEFAULT_UA = "Android-12-3.53.1-vivo-V2284A";
const LOGIN_UA =
"Dalvik/2.1.0 (Linux; U; Android 12; V2284A Build/ab8c0d1.1) APP/mi.health APPV/353001 MK/VjIyODRB SDKV/5.3.0.release.68 CPN/com.mi.health PassportSDK/";
+export const XIAOMI_REQUEST_TIMEOUT_MS = 30_000;
export class XiaomiError extends Error {}
export class TokenExpiredError extends XiaomiError {}
@@ -572,13 +573,25 @@ class XiaomiHttp {
const headers = new Headers(this.headers);
for (const [key, value] of Object.entries(init.headers ?? {})) headers.set(key, value);
if (this.cookies.size) headers.set("cookie", [...this.cookies].map(([key, value]) => `${key}=${value}`).join("; "));
- const response = await fetch(url, { ...init, headers, redirect: "manual" });
- for (const cookie of response.headers.getSetCookie?.() ?? []) {
- const pair = cookie.split(";", 1)[0] ?? "";
- const index = pair.indexOf("=");
- if (index > 0) this.cookies.set(pair.slice(0, index), pair.slice(index + 1));
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), XIAOMI_REQUEST_TIMEOUT_MS);
+ const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal;
+ try {
+ const response = await fetch(url, { ...init, headers, redirect: "manual", signal });
+ for (const cookie of response.headers.getSetCookie?.() ?? []) {
+ const pair = cookie.split(";", 1)[0] ?? "";
+ const index = pair.indexOf("=");
+ if (index > 0) this.cookies.set(pair.slice(0, index), pair.slice(index + 1));
+ }
+ const body = await response.arrayBuffer();
+ return new Response(body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers,
+ });
+ } finally {
+ clearTimeout(timeout);
}
- return response;
}
get cookiesSnapshot(): Record {
diff --git a/src/xiaomi/fds.ts b/src/xiaomi/fds.ts
index 440f741..e2abc12 100644
--- a/src/xiaomi/fds.ts
+++ b/src/xiaomi/fds.ts
@@ -1,5 +1,5 @@
import { gunzipSync, inflateSync } from "node:zlib";
-import { decodeFdsAes, type MiHealthClient } from "./client.js";
+import { decodeFdsAes, type MiHealthClient, XIAOMI_REQUEST_TIMEOUT_MS } from "./client.js";
const VALID_TYPES = [0, 1, 2, 6, 7, 8, 9, 10, 3, 4, 5];
export const FDS_SLEEP_DAILY_TYPE = 8;
@@ -144,7 +144,7 @@ export async function downloadAndDecryptSleepDetails(
if (!fileInfo) return null;
const url = typeof fileInfo.url === "string" ? fileInfo.url : "";
if (!url) return null;
- const response = await fetch(url);
+ const response = await fetch(url, { signal: AbortSignal.timeout(XIAOMI_REQUEST_TIMEOUT_MS) });
if (!response.ok) {
logFn(`FDS download returned HTTP ${response.status}`);
return null;
diff --git a/tests/config.test.ts b/tests/config.test.ts
index ca343ef..d4e43e7 100644
--- a/tests/config.test.ts
+++ b/tests/config.test.ts
@@ -31,4 +31,8 @@ describe("loadConfig", () => {
const config = loadConfig({ BOT_MODE: "http-only", ENABLE_FDS_SLEEP_DETAILS: "false" });
expect(config.ENABLE_FDS_SLEEP_DETAILS).toBe(false);
});
+
+ test("rejects an invalid timezone", () => {
+ expect(() => loadConfig({ BOT_MODE: "http-only", TZ: "not/a-timezone" })).toThrow(ConfigurationError);
+ });
});
diff --git a/tests/formatting.test.ts b/tests/formatting.test.ts
index 3ce228b..73e6b3c 100644
--- a/tests/formatting.test.ts
+++ b/tests/formatting.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
-import { esc, minutes, stepBar } from "../src/bot/formatting.js";
+import { epoch, esc, minutes, stepBar } from "../src/bot/formatting.js";
describe("bot formatting", () => {
test("escapes Telegram HTML and formats health values", () => {
@@ -7,4 +7,10 @@ describe("bot formatting", () => {
expect(minutes(396)).toBe("6 h 36 min");
expect(stepBar(5000)).toContain("50%");
});
+
+ test("formats timestamps in the configured timezone", () => {
+ const timestamp = Date.parse("2026-08-12T00:00:00Z") / 1000;
+ expect(epoch(timestamp, false, "en", "UTC")).toContain("00:00");
+ expect(epoch(timestamp, false, "en", "Europe/Moscow")).toContain("03:00");
+ });
});
diff --git a/tests/secure-files.test.ts b/tests/secure-files.test.ts
index f897a63..54b40e2 100644
--- a/tests/secure-files.test.ts
+++ b/tests/secure-files.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
-import { mkdtempSync, rmSync } from "node:fs";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { withExclusiveFileLock } from "../src/storage/secure-files.js";
@@ -11,4 +11,13 @@ describe("file locks", () => {
await expect(withExclusiveFileLock(path, async () => "second")).resolves.toBe("second");
rmSync(directory, { recursive: true, force: true });
});
+
+ test("reclaims a lock whose owner process is gone", async () => {
+ const directory = mkdtempSync(join(process.env.TMPDIR ?? "/tmp", "miband-lock-stale-"));
+ const path = join(directory, "sync.lock");
+ mkdirSync(`${path}.d`);
+ writeFileSync(join(`${path}.d`, "owner"), "pid=2147483647 time=0\n");
+ await expect(withExclusiveFileLock(path, async () => "reclaimed")).resolves.toBe("reclaimed");
+ rmSync(directory, { recursive: true, force: true });
+ });
});