fix sync reliability and runtime handling

This commit is contained in:
Alex Getman
2026-08-12 13:48:10 +03:00
parent 70387a94ad
commit 99a22e11bd
13 changed files with 289 additions and 132 deletions
+1 -1
View File
@@ -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`.
+4 -4
View File
@@ -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`.
+88 -57
View File
@@ -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)} · <b>${minutes(sleepTotal(sleep), locale)}</b>`,
`😴 ${epoch(rowNumber(sleep, "start_time"), false, locale, config.TZ)}${epoch(rowNumber(sleep, "end_time"), false, locale, config.TZ)} · <b>${minutes(sleepTotal(sleep), locale)}</b>`,
);
} 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<string, string> {
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<string, unknown>[]): number | null {
function averageBedtime(rows: Record<string, unknown>[], 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<string, unknown> {
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<string, unknown> {
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")} <b>${minutes(sleepTotal(sleep), locale)}</b>`,
`${t(locale, "sleep.quality")} <b>${rowNumber(sleep, "sleep_score") || t(locale, "common.na")}${rowNumber(sleep, "sleep_score") ? " / 100" : ""}</b>`,
`${t(locale, "sleep.bed")} <b>${epoch(start, false, locale)}${epoch(end, false, locale)}</b>`,
`${t(locale, "sleep.bed")} <b>${epoch(start, false, locale, config.TZ)}${epoch(end, false, locale, config.TZ)}</b>`,
`${t(locale, "sleep.resting-heart-rate")} <b>${rest && rowNumber(rest, "min_hr") ? `${rowNumber(rest, "min_hr")} bpm` : t(locale, "common.na")}</b>`,
"",
`${t(locale, "sleep.deep")} <code>${bar(deep)}</code> ${minutes(deep, locale)}`,
@@ -402,7 +430,7 @@ function dayText(config: AppConfig, uid: number, day: string): string {
const calories = data.calories as Record<string, unknown> | null;
const weight = data.weight as Record<string, unknown> | null;
const workouts = (data.workouts as Record<string, unknown>[]) ?? [];
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(
`• <b>${esc(workoutType(rowString(workout, "sport_type"), locale))}</b> ${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")})`,
`• <b>${esc(workoutType(rowString(workout, "sport_type"), locale))}</b> ${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)} <b>${label}</b> ${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<BotContext> {
}
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<BotContext>, config: AppConfig): TaskHand
const seenStatus = new Map<number, number>();
const run = async (): Promise<void> => {
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<BotContext>, 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 });
}
+19 -5
View File
@@ -73,6 +73,7 @@ const SPORT_TYPES: Record<Locale, Record<string, string>> = {
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('"', "&quot;");
}
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 `<code>[${"█".repeat(blocks)}${"░".repeat(10 - blocks)}]</code> ${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;
}
+10 -1
View File
@@ -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<typeof envSchema> & {
+6
View File
@@ -68,9 +68,15 @@ async function main(): Promise<void> {
process.exitCode = 1;
});
} else if (config.BOT_MODE === "webhook" && config.PUBLIC_WEBHOOK_URL && 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 });
+11 -5
View File
@@ -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<T>(path: string, action: () => Promise<T>): Promise<T> {
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 {
@@ -98,24 +100,28 @@ export async function withExclusiveFileLock<T>(path: string, action: () => Promi
stale = true;
}
}
} catch {
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.
}
+103 -44
View File
@@ -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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
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<SyncResult
const steps = await client.getSteps(relativeUid, config.QUERY_DURATION);
withHealthDb(config, uid, (database) => {
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<SyncResult
await syncWorkouts(client, relativeUid, config, uid, counters);
if (config.ENABLE_FDS_SLEEP_DETAILS) await syncFds(client, relativeUid, config, uid, sleep, counters);
saveAuthToken(path, client.auth.token);
writeStatus(statusPath, latestSteps, latestHeartRate, latestSleep);
writeStatus(statusPath, latestSteps, latestHeartRate, latestSleep, config.TZ);
return { success: true, userId: uid, counters };
} catch (error) {
const message =
@@ -225,7 +251,7 @@ async function syncPointMetrics(
withHealthDb(config, uid, (database) => {
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<string, unknown>)[field] ?? 0);
return Number(raw);
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
const object = raw as Record<string, unknown>;
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<string, Record<string, number>>();
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<string, number>,
): Promise<void> {
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,6 +407,7 @@ async function syncWorkouts(
return record(raw);
})();
const recordWatermark = Number(entry.watermark ?? 0);
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);
@@ -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<void> {
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);
}
}
+15 -2
View File
@@ -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" });
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));
}
return response;
const body = await response.arrayBuffer();
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
} finally {
clearTimeout(timeout);
}
}
get cookiesSnapshot(): Record<string, string> {
+2 -2
View File
@@ -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;
+4
View File
@@ -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);
});
});
+7 -1
View File
@@ -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");
});
});
+10 -1
View File
@@ -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 });
});
});