mirror of
https://github.com/alexgetmancom/miband-bot.git
synced 2026-09-05 18:16:18 +03:00
fix sync reliability and runtime handling
This commit is contained in:
@@ -37,7 +37,7 @@ SQLite databases, and exports private.
|
|||||||
bun install
|
bun install
|
||||||
bun run check
|
bun run check
|
||||||
bun run dev
|
bun run dev
|
||||||
`
|
```
|
||||||
|
|
||||||
The service exposes `/healthz` and `/readyz` on port `8080`.
|
The service exposes `/healthz` and `/readyz` on port `8080`.
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -20,24 +20,24 @@
|
|||||||
|
|
||||||
## Быстрый запуск
|
## Быстрый запуск
|
||||||
|
|
||||||
`
|
```
|
||||||
cp .env.example secrets.env
|
cp .env.example secrets.env
|
||||||
# Укажите TELEGRAM_BOT_TOKEN в secrets.env
|
# Укажите TELEGRAM_BOT_TOKEN в secrets.env
|
||||||
bun install
|
bun install
|
||||||
bun run check
|
bun run check
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
`
|
```
|
||||||
|
|
||||||
Рабочие данные находятся в `./data`. Файлы `secrets.env`, `data/`, токены Xiaomi,
|
Рабочие данные находятся в `./data`. Файлы `secrets.env`, `data/`, токены Xiaomi,
|
||||||
SQLite-базы и экспорты должны оставаться приватными.
|
SQLite-базы и экспорты должны оставаться приватными.
|
||||||
|
|
||||||
## Разработка
|
## Разработка
|
||||||
|
|
||||||
`
|
```
|
||||||
bun install
|
bun install
|
||||||
bun run check
|
bun run check
|
||||||
bun run dev
|
bun run dev
|
||||||
`
|
```
|
||||||
|
|
||||||
Сервис открывает `/healthz` и `/readyz` на порту `8080`.
|
Сервис открывает `/healthz` и `/readyz` на порту `8080`.
|
||||||
|
|
||||||
|
|||||||
+88
-57
@@ -101,7 +101,7 @@ function mainMenuText(config: AppConfig, uid: number): string {
|
|||||||
lines.push("");
|
lines.push("");
|
||||||
if (sleep) {
|
if (sleep) {
|
||||||
lines.push(
|
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"));
|
} else lines.push(t(locale, "main.no-sleep"));
|
||||||
lines.push("");
|
lines.push("");
|
||||||
@@ -128,7 +128,7 @@ function workoutsText(config: AppConfig, uid: number): string {
|
|||||||
"",
|
"",
|
||||||
...rows.map(
|
...rows.map(
|
||||||
(row) =>
|
(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");
|
].join("\n");
|
||||||
}
|
}
|
||||||
@@ -162,40 +162,68 @@ function statusText(config: AppConfig, uid: number): string {
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function localDay(offset = 0): string {
|
function zonedDateParts(value: number, timeZone: string): Record<string, string> {
|
||||||
const value = new Date(Date.now() + offset * 86_400_000);
|
return Object.fromEntries(
|
||||||
const parts = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Moscow" }).formatToParts(value);
|
new Intl.DateTimeFormat("en-US", {
|
||||||
const year = parts.find((part) => part.type === "year")?.value ?? "1970";
|
timeZone,
|
||||||
const month = parts.find((part) => part.type === "month")?.value ?? "01";
|
year: "numeric",
|
||||||
const day = parts.find((part) => part.type === "day")?.value ?? "01";
|
month: "2-digit",
|
||||||
return `${year}-${month}-${day}`;
|
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] {
|
function localDay(timeZone: string, offset = 0): string {
|
||||||
const start = Math.floor(new Date(`${day}T00:00:00+03:00`).getTime() / 1000);
|
const parts = zonedDateParts(Date.now(), timeZone);
|
||||||
return [start, start + 86_400];
|
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`);
|
const value = new Date(`${day}T12:00:00Z`);
|
||||||
return new Intl.DateTimeFormat(numberLocale(locale), { weekday: "short", timeZone: "Europe/Moscow" })
|
return new Intl.DateTimeFormat(numberLocale(locale), { weekday: "short", timeZone }).format(value).replace(".", "");
|
||||||
.format(value)
|
|
||||||
.replace(".", "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function averageBedtime(rows: Record<string, unknown>[]): number | null {
|
function averageBedtime(rows: Record<string, unknown>[], timeZone: string): number | null {
|
||||||
const offsets = rows
|
const offsets = rows
|
||||||
.map((row) => rowNumber(row, "start_time"))
|
.map((row) => rowNumber(row, "start_time"))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((time) => {
|
.map((time) => {
|
||||||
const hour = Number(
|
const hour = Number(
|
||||||
new Intl.DateTimeFormat("en-US", { hour: "numeric", hour12: false, timeZone: "Europe/Moscow" }).format(
|
new Intl.DateTimeFormat("en-US", { hour: "numeric", hour12: false, timeZone }).format(time * 1000),
|
||||||
time * 1000,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const minute = Number(
|
|
||||||
new Intl.DateTimeFormat("en-US", { minute: "numeric", timeZone: "Europe/Moscow" }).format(time * 1000),
|
|
||||||
);
|
);
|
||||||
|
const minute = Number(new Intl.DateTimeFormat("en-US", { minute: "numeric", timeZone }).format(time * 1000));
|
||||||
const offset = hour * 60 + minute;
|
const offset = hour * 60 + minute;
|
||||||
return offset > 720 ? offset - 1440 : offset;
|
return offset > 720 ? offset - 1440 : offset;
|
||||||
});
|
});
|
||||||
@@ -225,7 +253,7 @@ function metricStats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function daySummary(config: AppConfig, uid: number, day: string): Record<string, unknown> {
|
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(
|
const sleep = dbRow(
|
||||||
config,
|
config,
|
||||||
uid,
|
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> {
|
function periodSummary(config: AppConfig, uid: number, days: number): Record<string, unknown> {
|
||||||
const end = localDay();
|
const end = localDay(config.TZ);
|
||||||
const start = localDay(-(Math.max(1, days) - 1));
|
const start = localDay(config.TZ, -(Math.max(1, days) - 1));
|
||||||
const [startEpoch] = dayEpochBounds(start);
|
const [startEpoch] = dayEpochBounds(start, config.TZ);
|
||||||
const [, endEpoch] = dayEpochBounds(end);
|
const [, endEpoch] = dayEpochBounds(end, config.TZ);
|
||||||
let weight = dbRows(
|
let weight = dbRows(
|
||||||
config,
|
config,
|
||||||
uid,
|
uid,
|
||||||
@@ -349,7 +377,7 @@ function sleepText(config: AppConfig, uid: number): string {
|
|||||||
const dateLabel = new Intl.DateTimeFormat(numberLocale(locale), {
|
const dateLabel = new Intl.DateTimeFormat(numberLocale(locale), {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
timeZone: "Europe/Moscow",
|
timeZone: config.TZ,
|
||||||
}).format(date);
|
}).format(date);
|
||||||
const deep = rowNumber(sleep, "deep_sleep_min");
|
const deep = rowNumber(sleep, "deep_sleep_min");
|
||||||
const light = rowNumber(sleep, "light_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.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.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.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)}`,
|
`${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 calories = data.calories as Record<string, unknown> | null;
|
||||||
const weight = data.weight as Record<string, unknown> | null;
|
const weight = data.weight as Record<string, unknown> | null;
|
||||||
const workouts = (data.workouts as Record<string, unknown>[]) ?? [];
|
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) }), ""];
|
const lines = [t(locale, "day.details", { day: esc(day), label: esc(dayLabel) }), ""];
|
||||||
if (steps)
|
if (steps)
|
||||||
lines.push(
|
lines.push(
|
||||||
@@ -419,14 +447,17 @@ function dayText(config: AppConfig, uid: number, day: string): string {
|
|||||||
lines.push(
|
lines.push(
|
||||||
t(locale, "day.energy", {
|
t(locale, "day.energy", {
|
||||||
total: rowNumber(calories, "total_cal").toFixed(0),
|
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) }));
|
} else if (steps) lines.push(t(locale, "day.energy-simple", { calories: rowNumber(steps, "calories").toFixed(0) }));
|
||||||
lines.push("");
|
lines.push("");
|
||||||
if (sleep)
|
if (sleep)
|
||||||
lines.push(
|
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"));
|
else lines.push(t(locale, "day.no-sleep"));
|
||||||
lines.push("");
|
lines.push("");
|
||||||
@@ -465,7 +496,7 @@ function dayText(config: AppConfig, uid: number, day: string): string {
|
|||||||
lines.push("", t(locale, "day.training"));
|
lines.push("", t(locale, "day.training"));
|
||||||
for (const workout of workouts)
|
for (const workout of workouts)
|
||||||
lines.push(
|
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");
|
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 {
|
function historyText(config: AppConfig, uid: number, days = 7): string {
|
||||||
const locale = localeOf(config, uid);
|
const locale = localeOf(config, uid);
|
||||||
const end = localDay();
|
const end = localDay(config.TZ);
|
||||||
const start = localDay(-(days - 1));
|
const start = localDay(config.TZ, -(days - 1));
|
||||||
const steps = dbRows(config, uid, "SELECT date,total_steps FROM steps_daily WHERE date BETWEEN ? AND ?", [
|
const steps = dbRows(config, uid, "SELECT date,total_steps FROM steps_daily WHERE date BETWEEN ? AND ?", [
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
@@ -502,7 +533,7 @@ function historyText(config: AppConfig, uid: number, days = 7): string {
|
|||||||
const label = new Intl.DateTimeFormat(numberLocale(locale), {
|
const label = new Intl.DateTimeFormat(numberLocale(locale), {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
timeZone: "Europe/Moscow",
|
timeZone: config.TZ,
|
||||||
}).format(date);
|
}).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")}`;
|
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;
|
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 avgSteps = averages(steps.map((row) => rowNumber(row, "total_steps")));
|
||||||
const avgSleep = averages(sleep.map((row) => sleepTotal(row)));
|
const avgSleep = averages(sleep.map((row) => sleepTotal(row)));
|
||||||
const avgBed = formatBedtime(averageBedtime(sleep), locale);
|
const avgBed = formatBedtime(averageBedtime(sleep, config.TZ), locale);
|
||||||
const previousStart = localDay(-13);
|
const previousStart = localDay(config.TZ, -13);
|
||||||
const previousEnd = localDay(-7);
|
const previousEnd = localDay(config.TZ, -7);
|
||||||
const previousSteps = dbRows(config, uid, "SELECT total_steps FROM steps_daily WHERE date BETWEEN ? AND ?", [
|
const previousSteps = dbRows(config, uid, "SELECT total_steps FROM steps_daily WHERE date BETWEEN ? AND ?", [
|
||||||
previousStart,
|
previousStart,
|
||||||
previousEnd,
|
previousEnd,
|
||||||
@@ -560,10 +591,10 @@ function weeklyText(config: AppConfig, uid: number): string {
|
|||||||
"",
|
"",
|
||||||
t(locale, "weekly.records", {
|
t(locale, "weekly.records", {
|
||||||
steps: recordSteps
|
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"),
|
: t(locale, "common.na"),
|
||||||
sleep: recordSleep
|
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"),
|
: t(locale, "common.na"),
|
||||||
}),
|
}),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
@@ -646,8 +677,8 @@ function familyText(config: AppConfig, uid: number): string {
|
|||||||
const [first, second] = config.allowedUserIds;
|
const [first, second] = config.allowedUserIds;
|
||||||
if (first === undefined || second === undefined) return t(locale, "family.no-data");
|
if (first === undefined || second === undefined) return t(locale, "family.no-data");
|
||||||
const stats = (uid: number) => {
|
const stats = (uid: number) => {
|
||||||
const start = localDay(-6);
|
const start = localDay(config.TZ, -6);
|
||||||
const end = localDay();
|
const end = localDay(config.TZ);
|
||||||
const steps = dbRows(config, uid, "SELECT total_steps,distance_m FROM steps_daily WHERE date BETWEEN ? AND ?", [
|
const steps = dbRows(config, uid, "SELECT total_steps,distance_m FROM steps_daily WHERE date BETWEEN ? AND ?", [
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
@@ -667,7 +698,7 @@ function familyText(config: AppConfig, uid: number): string {
|
|||||||
avgSleep: sleep.length
|
avgSleep: sleep.length
|
||||||
? Math.round(sleep.reduce((sum, row) => sum + rowNumber(row, "total_duration_min"), 0) / sleep.length)
|
? Math.round(sleep.reduce((sum, row) => sum + rowNumber(row, "total_duration_min"), 0) / sleep.length)
|
||||||
: 0,
|
: 0,
|
||||||
bedtime: averageBedtime(sleep),
|
bedtime: averageBedtime(sleep, config.TZ),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const a = stats(first);
|
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");
|
if (first === undefined || second === undefined) return t(locale, "versus.no-data");
|
||||||
const names = [USER_NAMES[first] ?? `User ${first}`, USER_NAMES[second] ?? `User ${second}`];
|
const names = [USER_NAMES[first] ?? `User ${first}`, USER_NAMES[second] ?? `User ${second}`];
|
||||||
const data = (uid: number) => {
|
const data = (uid: number) => {
|
||||||
const end = localDay();
|
const end = localDay(config.TZ);
|
||||||
const start = localDay(-(days - 1));
|
const start = localDay(config.TZ, -(days - 1));
|
||||||
const steps = dbRows(config, uid, "SELECT total_steps FROM steps_daily WHERE date BETWEEN ? AND ?", [
|
const steps = dbRows(config, uid, "SELECT total_steps FROM steps_daily WHERE date BETWEEN ? AND ?", [
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
@@ -734,7 +765,7 @@ function versusText(config: AppConfig, uid: number, days: number): string {
|
|||||||
sleep: sleep.length
|
sleep: sleep.length
|
||||||
? Math.round(sleep.reduce((sum, row) => sum + rowNumber(row, "total_duration_min"), 0) / sleep.length)
|
? Math.round(sleep.reduce((sum, row) => sum + rowNumber(row, "total_duration_min"), 0) / sleep.length)
|
||||||
: 0,
|
: 0,
|
||||||
bedtime: averageBedtime(sleep),
|
bedtime: averageBedtime(sleep, config.TZ),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const a = data(first);
|
const a = data(first);
|
||||||
@@ -898,7 +929,7 @@ function historyKb(config: AppConfig, uid: number, days = 7, locale = localeOf(c
|
|||||||
new Intl.DateTimeFormat(numberLocale(locale), {
|
new Intl.DateTimeFormat(numberLocale(locale), {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
timeZone: "Europe/Moscow",
|
timeZone: config.TZ,
|
||||||
}).format(new Date(`${day}T12:00:00Z`)),
|
}).format(new Date(`${day}T12:00:00Z`)),
|
||||||
`day:${day}`,
|
`day:${day}`,
|
||||||
] as [string, string],
|
] as [string, string],
|
||||||
@@ -908,7 +939,7 @@ function historyKb(config: AppConfig, uid: number, days = 7, locale = localeOf(c
|
|||||||
return keyboard(buttons);
|
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`);
|
const previous = new Date(`${day}T12:00:00Z`);
|
||||||
previous.setUTCDate(previous.getUTCDate() - 1);
|
previous.setUTCDate(previous.getUTCDate() - 1);
|
||||||
const next = new Date(`${day}T12:00:00Z`);
|
const next = new Date(`${day}T12:00:00Z`);
|
||||||
@@ -919,8 +950,8 @@ function dayKb(day: string, locale: Locale): InlineKeyboard {
|
|||||||
[
|
[
|
||||||
[`◀️ ${iso(previous)}`, `day:${iso(previous)}`],
|
[`◀️ ${iso(previous)}`, `day:${iso(previous)}`],
|
||||||
[
|
[
|
||||||
nextDay > localDay() ? t(locale, "menu.home") : `${nextDay} ▶️`,
|
nextDay > localDay(timeZone) ? t(locale, "menu.home") : `${nextDay} ▶️`,
|
||||||
nextDay > localDay() ? "menu:main" : `day:${nextDay}`,
|
nextDay > localDay(timeZone) ? "menu:main" : `day:${nextDay}`,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[[t(locale, "menu.calendar"), "menu:history"]],
|
[[t(locale, "menu.calendar"), "menu:history"]],
|
||||||
@@ -1135,7 +1166,7 @@ export function createBot(config: AppConfig): Bot<BotContext> {
|
|||||||
}
|
}
|
||||||
if (data.startsWith("day:")) {
|
if (data.startsWith("day:")) {
|
||||||
const day = data.slice("day:".length);
|
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:more") return showMenu(context, config, moreText(config, uid), moreKb(locale));
|
||||||
if (data === "menu:language")
|
if (data === "menu:language")
|
||||||
@@ -1217,9 +1248,9 @@ export function startBotTasks(bot: Bot<BotContext>, config: AppConfig): TaskHand
|
|||||||
const seenStatus = new Map<number, number>();
|
const seenStatus = new Map<number, number>();
|
||||||
const run = async (): Promise<void> => {
|
const run = async (): Promise<void> => {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
const today = new Date();
|
const now = zonedDateParts(Date.now(), config.TZ);
|
||||||
const weeklyDate = today.toISOString().slice(0, 10);
|
const weeklyDate = `${now.year}-${now.month}-${now.day}`;
|
||||||
if (today.getDay() === 0 && today.getHours() === 21 && today.getMinutes() === 0 && lastWeeklyDate !== weeklyDate) {
|
if (now.weekday === "Sun" && Number(now.hour) === 21 && Number(now.minute) === 0 && lastWeeklyDate !== weeklyDate) {
|
||||||
lastWeeklyDate = weeklyDate;
|
lastWeeklyDate = weeklyDate;
|
||||||
for (const uid of config.allowedUserIds) {
|
for (const uid of config.allowedUserIds) {
|
||||||
try {
|
try {
|
||||||
@@ -1239,11 +1270,11 @@ export function startBotTasks(bot: Bot<BotContext>, config: AppConfig): TaskHand
|
|||||||
try {
|
try {
|
||||||
const modified = statSync(canonicalUserStatusPath(config, uid)).mtimeMs;
|
const modified = statSync(canonicalUserStatusPath(config, uid)).mtimeMs;
|
||||||
if (modified <= (seenStatus.get(uid) ?? 0)) continue;
|
if (modified <= (seenStatus.get(uid) ?? 0)) continue;
|
||||||
seenStatus.set(uid, modified);
|
|
||||||
await bot.api.editMessageText(uid, menuMessageId, mainMenuText(config, uid), {
|
await bot.api.editMessageText(uid, menuMessageId, mainMenuText(config, uid), {
|
||||||
parse_mode: "HTML",
|
parse_mode: "HTML",
|
||||||
reply_markup: mainKb(localeOf(config, uid)),
|
reply_markup: mainKb(localeOf(config, uid)),
|
||||||
});
|
});
|
||||||
|
seenStatus.set(uid, modified);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log("debug", "Automatic menu refresh skipped", { userId: uid, error });
|
log("debug", "Automatic menu refresh skipped", { userId: uid, error });
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-5
@@ -73,6 +73,7 @@ const SPORT_TYPES: Record<Locale, Record<string, string>> = {
|
|||||||
martial_arts: "Artes marciales",
|
martial_arts: "Artes marciales",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const DEFAULT_TIME_ZONE = "Europe/Moscow";
|
||||||
|
|
||||||
export function esc(value: unknown): string {
|
export function esc(value: unknown): string {
|
||||||
return String(value)
|
return String(value)
|
||||||
@@ -82,12 +83,12 @@ export function esc(value: unknown): string {
|
|||||||
.replaceAll('"', """);
|
.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);
|
const seconds = Number(value);
|
||||||
if (!seconds) return t(locale, "common.na");
|
if (!seconds) return t(locale, "common.na");
|
||||||
const date = new Date(seconds * 1000);
|
const date = new Date(seconds * 1000);
|
||||||
return new Intl.DateTimeFormat(locale === "ru" ? "ru-RU" : locale === "es" ? "es-ES" : "en-GB", {
|
return new Intl.DateTimeFormat(locale === "ru" ? "ru-RU" : locale === "es" ? "es-ES" : "en-GB", {
|
||||||
timeZone: "Europe/Moscow",
|
timeZone,
|
||||||
...(withDate ? { dateStyle: "short" } : {}),
|
...(withDate ? { dateStyle: "short" } : {}),
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
}).format(date);
|
}).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}%`;
|
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");
|
if (!day) return t(locale, "common.day");
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = dateKey(Date.now(), timeZone);
|
||||||
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
const yesterday = dateKey(Date.now() - 86_400_000, timeZone);
|
||||||
return day === today ? t(locale, "common.today") : day === yesterday ? t(locale, "common.yesterday") : day;
|
return day === today ? t(locale, "common.today") : day === yesterday ? t(locale, "common.yesterday") : day;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -15,6 +15,15 @@ const booleanText = z.preprocess((value) => {
|
|||||||
return value;
|
return value;
|
||||||
}, z.boolean());
|
}, 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 {
|
export class ConfigurationError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -75,7 +84,7 @@ const envSchema = z.object({
|
|||||||
AUTO_MENU_REFRESH_INTERVAL: z.coerce.number().positive().default(30),
|
AUTO_MENU_REFRESH_INTERVAL: z.coerce.number().positive().default(30),
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
||||||
BIND_HOST: z.string().min(1).default("127.0.0.1"),
|
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> & {
|
export type AppConfig = z.infer<typeof envSchema> & {
|
||||||
|
|||||||
+9
-3
@@ -68,9 +68,15 @@ async function main(): Promise<void> {
|
|||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
} else if (config.BOT_MODE === "webhook" && config.PUBLIC_WEBHOOK_URL && config.TELEGRAM_WEBHOOK_SECRET) {
|
} else if (config.BOT_MODE === "webhook" && config.PUBLIC_WEBHOOK_URL && config.TELEGRAM_WEBHOOK_SECRET) {
|
||||||
await bot.api.setWebhook(`${config.PUBLIC_WEBHOOK_URL}/telegram/webhook`, {
|
try {
|
||||||
secret_token: config.TELEGRAM_WEBHOOK_SECRET,
|
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 });
|
log("info", "HTTP server listening", { address: `http://${config.BIND_HOST}:${config.PORT}`, mode: config.BOT_MODE });
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
chmodSync,
|
chmodSync,
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
renameSync,
|
renameSync,
|
||||||
rmdirSync,
|
rmdirSync,
|
||||||
|
statSync,
|
||||||
unlinkSync,
|
unlinkSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
const SECRET_MODE = 0o600;
|
const SECRET_MODE = 0o600;
|
||||||
|
const STALE_LOCK_AGE_MS = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export function writeTextAtomic(path: string, text: string, mode?: number): void {
|
export function writeTextAtomic(path: string, text: string, mode?: number): void {
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
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> {
|
export async function withExclusiveFileLock<T>(path: string, action: () => Promise<T>): Promise<T> {
|
||||||
const lockDir = `${path}.d`;
|
const lockDir = `${path}.d`;
|
||||||
|
const ownerPath = join(lockDir, "owner");
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
try {
|
try {
|
||||||
mkdirSync(lockDir);
|
mkdirSync(lockDir);
|
||||||
} catch {
|
} catch {
|
||||||
let stale = false;
|
let stale = false;
|
||||||
try {
|
try {
|
||||||
const lockText = readFileSync(path, "utf8");
|
const lockText = readFileSync(ownerPath, "utf8");
|
||||||
const pid = Number(lockText.match(/pid=(\d+)/)?.[1] ?? 0);
|
const pid = Number(lockText.match(/pid=(\d+)/)?.[1] ?? 0);
|
||||||
if (pid > 0) {
|
if (pid > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -99,23 +101,27 @@ export async function withExclusiveFileLock<T>(path: string, action: () => Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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}`);
|
if (!stale) throw new LockUnavailable(`Lock is already held: ${path}`);
|
||||||
try {
|
try {
|
||||||
|
unlinkSync(ownerPath);
|
||||||
rmdirSync(lockDir);
|
rmdirSync(lockDir);
|
||||||
if (existsSync(path)) unlinkSync(path);
|
|
||||||
mkdirSync(lockDir);
|
mkdirSync(lockDir);
|
||||||
} catch {
|
} catch {
|
||||||
throw new LockUnavailable(`Lock is already held: ${path}`);
|
throw new LockUnavailable(`Lock is already held: ${path}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
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();
|
return await action();
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
unlinkSync(path);
|
unlinkSync(ownerPath);
|
||||||
} catch {
|
} catch {
|
||||||
// Best effort cleanup.
|
// Best effort cleanup.
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-45
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
|||||||
import { type AppConfig, canonicalUserDbPath, canonicalUserStatusPath, tokenPath, userId } from "./config.js";
|
import { type AppConfig, canonicalUserDbPath, canonicalUserStatusPath, tokenPath, userId } from "./config.js";
|
||||||
import { log } from "./logger.js";
|
import { log } from "./logger.js";
|
||||||
import { initHealthDb, withHealthDb } from "./storage/health.js";
|
import { initHealthDb, withHealthDb } from "./storage/health.js";
|
||||||
|
import { getState, migrateDatabase, openDatabase, setState } from "./storage/kv.js";
|
||||||
import {
|
import {
|
||||||
type AuthToken,
|
type AuthToken,
|
||||||
readToken,
|
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>) : {};
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEpoch(value: number): string | null {
|
function formatEpoch(value: number, timeZone: string): string | null {
|
||||||
return value ? new Date(value * 1000).toISOString().replace("T", " ").slice(0, 19) : 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 {
|
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);
|
const steps = await client.getSteps(relativeUid, config.QUERY_DURATION);
|
||||||
withHealthDb(config, uid, (database) => {
|
withHealthDb(config, uid, (database) => {
|
||||||
for (const item of steps) {
|
for (const item of steps) {
|
||||||
const date = new Date(item.time * 1000).toISOString().slice(0, 10);
|
const date = dateInTimeZone(item.time, config.TZ);
|
||||||
database
|
database
|
||||||
.query(
|
.query(
|
||||||
"INSERT OR REPLACE INTO steps_daily (date,total_steps,calories,distance_m,last_sync) VALUES (?,?,?,?,?)",
|
"INSERT OR REPLACE INTO steps_daily (date,total_steps,calories,distance_m,last_sync) VALUES (?,?,?,?,?)",
|
||||||
)
|
)
|
||||||
.run(date, item.steps, item.calories, item.distance, timestamp());
|
.run(date, item.steps, item.calories, item.distance, timestamp());
|
||||||
increment(counters, "steps_daily");
|
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);
|
const sleep = await client.getSleep(relativeUid, config.QUERY_DURATION);
|
||||||
for (const item of sleep) {
|
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) => {
|
withHealthDb(config, uid, (database) => {
|
||||||
const segments = item.segment_details;
|
const segments = item.segment_details;
|
||||||
const start = segments.length ? Math.min(...segments.map((segment) => segment.bedtime)) : 0;
|
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);
|
await syncWorkouts(client, relativeUid, config, uid, counters);
|
||||||
if (config.ENABLE_FDS_SLEEP_DETAILS) await syncFds(client, relativeUid, config, uid, sleep, counters);
|
if (config.ENABLE_FDS_SLEEP_DETAILS) await syncFds(client, relativeUid, config, uid, sleep, counters);
|
||||||
saveAuthToken(path, client.auth.token);
|
saveAuthToken(path, client.auth.token);
|
||||||
writeStatus(statusPath, latestSteps, latestHeartRate, latestSleep);
|
writeStatus(statusPath, latestSteps, latestHeartRate, latestSleep, config.TZ);
|
||||||
return { success: true, userId: uid, counters };
|
return { success: true, userId: uid, counters };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
@@ -225,7 +251,7 @@ async function syncPointMetrics(
|
|||||||
withHealthDb(config, uid, (database) => {
|
withHealthDb(config, uid, (database) => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const value = parseMetricValue(item, field);
|
const value = parseMetricValue(item, field);
|
||||||
if (!value) continue;
|
if (value === null || value <= 0) continue;
|
||||||
const query =
|
const query =
|
||||||
table === "blood_oxygen"
|
table === "blood_oxygen"
|
||||||
? "INSERT OR IGNORE INTO blood_oxygen (timestamp,spo2,type) VALUES (?,?,?)"
|
? "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 = (() => {
|
const raw = (() => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(item.value) as unknown;
|
return JSON.parse(item.value) as unknown;
|
||||||
@@ -251,9 +277,17 @@ function parseMetricValue(item: AggregatedDataItem, field: string): number {
|
|||||||
return item.value;
|
return item.value;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
if (raw && typeof raw === "object" && !Array.isArray(raw))
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||||
return Number((raw as Record<string, unknown>)[field] ?? 0);
|
const object = raw as Record<string, unknown>;
|
||||||
return Number(raw);
|
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(
|
async function syncCalories(
|
||||||
@@ -267,17 +301,21 @@ async function syncCalories(
|
|||||||
const end = Math.floor(Date.now() / 1000);
|
const end = Math.floor(Date.now() / 1000);
|
||||||
const values = new Map<string, Record<string, number>>();
|
const values = new Map<string, Record<string, number>>();
|
||||||
for (const [key, field, valueKey] of [
|
for (const [key, field, valueKey] of [
|
||||||
["calories", "total_cal", "calories"],
|
["calories", "total_cal", ["calories", "total_cal", "total_calories"]],
|
||||||
["intensity", "intensity_minutes", "duration"],
|
["intensity", "intensity_minutes", ["duration"]],
|
||||||
["valid_stand", "valid_stand_hours", "count"],
|
["valid_stand", "valid_stand_hours", ["count"]],
|
||||||
] as const) {
|
] as const) {
|
||||||
try {
|
try {
|
||||||
for (const item of await client.getAggregatedData(relativeUid, key, start, end, config.QUERY_DURATION)) {
|
for (const item of await client.getAggregatedData(relativeUid, key, start, end, config.QUERY_DURATION)) {
|
||||||
const value = parseMetricValue(item, valueKey);
|
const value = parseMetricValue(item, valueKey);
|
||||||
if (!value) continue;
|
if (value === null || value <= 0) continue;
|
||||||
const date = new Date(item.time * 1000).toISOString().slice(0, 10);
|
const date = dateInTimeZone(item.time, config.TZ);
|
||||||
const entry = values.get(date) ?? {};
|
const entry = values.get(date) ?? {};
|
||||||
entry[field] = field === "total_cal" ? (entry[field] ?? 0) + value : Math.max(entry[field] ?? 0, value);
|
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);
|
values.set(date, entry);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -288,7 +326,14 @@ async function syncCalories(
|
|||||||
for (const [date, value] of values) {
|
for (const [date, value] of values) {
|
||||||
const result = database
|
const result = database
|
||||||
.query(
|
.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(
|
.run(
|
||||||
date,
|
date,
|
||||||
@@ -334,9 +379,10 @@ async function syncWorkouts(
|
|||||||
counters: Record<string, number>,
|
counters: Record<string, number>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
let watermark = 0;
|
let watermark = readWorkoutWatermark(config, uid);
|
||||||
let hasMore = true;
|
let hasMore = true;
|
||||||
while (hasMore) {
|
while (hasMore) {
|
||||||
|
const previousWatermark = watermark;
|
||||||
const response = await client.request("GET", "/app/v1/data/get_sport_records_by_watermark", {
|
const response = await client.request("GET", "/app/v1/data/get_sport_records_by_watermark", {
|
||||||
relative_uid: relativeUid,
|
relative_uid: relativeUid,
|
||||||
watermark,
|
watermark,
|
||||||
@@ -361,7 +407,8 @@ async function syncWorkouts(
|
|||||||
return record(raw);
|
return record(raw);
|
||||||
})();
|
})();
|
||||||
const recordWatermark = Number(entry.watermark ?? 0);
|
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 workoutId = String(entry.sid ?? entry.did ?? "");
|
||||||
const start = Number(value.start_time ?? entry.time ?? 0);
|
const start = Number(value.start_time ?? entry.time ?? 0);
|
||||||
const duration = Number(value.duration ?? 0);
|
const duration = Number(value.duration ?? 0);
|
||||||
@@ -385,6 +432,11 @@ async function syncWorkouts(
|
|||||||
if (resultRow.changes > 0) increment(counters, "workouts");
|
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 });
|
log("debug", "Workout watermark sync completed", { userId: uid });
|
||||||
} catch (error) {
|
} 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(
|
async function syncFds(
|
||||||
client: MiHealthClient,
|
client: MiHealthClient,
|
||||||
relativeUid: number,
|
relativeUid: number,
|
||||||
@@ -438,53 +516,34 @@ function writeStatus(
|
|||||||
steps: StepData | undefined,
|
steps: StepData | undefined,
|
||||||
heartRate: { timestamp: number; value: number } | undefined,
|
heartRate: { timestamp: number; value: number } | undefined,
|
||||||
sleep: SleepData | undefined,
|
sleep: SleepData | undefined,
|
||||||
|
timeZone: string,
|
||||||
): void {
|
): void {
|
||||||
writeJsonAtomic(path, {
|
writeJsonAtomic(path, {
|
||||||
last_sync: timestamp(),
|
last_sync: timestamp(),
|
||||||
last_sync_time: formatEpoch(timestamp()),
|
last_sync_time: formatEpoch(timestamp(), timeZone),
|
||||||
today: steps
|
today: steps
|
||||||
? {
|
? {
|
||||||
date: new Date(steps.time * 1000).toISOString().slice(0, 10),
|
date: dateInTimeZone(steps.time, timeZone),
|
||||||
steps: steps.steps,
|
steps: steps.steps,
|
||||||
calories: steps.calories,
|
calories: steps.calories,
|
||||||
distance_m: steps.distance,
|
distance_m: steps.distance,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
latest_heart_rate: heartRate
|
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,
|
: null,
|
||||||
latest_sleep: sleep
|
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,
|
light_sleep_min: sleep.sleep_light_duration,
|
||||||
deep_sleep_min: sleep.sleep_deep_duration,
|
deep_sleep_min: sleep.sleep_deep_duration,
|
||||||
rem_sleep_min: sleep.sleep_rem_duration,
|
rem_sleep_min: sleep.sleep_rem_duration,
|
||||||
awake_min: sleep.sleep_awake_duration,
|
awake_min: sleep.sleep_awake_duration,
|
||||||
total_sleep_min: sleep.total_duration || sleep.sleep_light_duration + sleep.sleep_deep_duration,
|
total_sleep_min: sleep.total_duration || sleep.sleep_light_duration + sleep.sleep_deep_duration,
|
||||||
sleep_score: sleep.sleep_score,
|
sleep_score: sleep.sleep_score,
|
||||||
start_time: formatEpoch(Math.min(...sleep.segment_details.map((segment) => segment.bedtime), 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)),
|
end_time: formatEpoch(Math.max(...sleep.segment_details.map((segment) => segment.wake_up_time), 0), timeZone),
|
||||||
}
|
}
|
||||||
: null,
|
: 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+19
-6
@@ -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 DEFAULT_UA = "Android-12-3.53.1-vivo-V2284A";
|
||||||
const LOGIN_UA =
|
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/";
|
"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 XiaomiError extends Error {}
|
||||||
export class TokenExpiredError extends XiaomiError {}
|
export class TokenExpiredError extends XiaomiError {}
|
||||||
@@ -572,13 +573,25 @@ class XiaomiHttp {
|
|||||||
const headers = new Headers(this.headers);
|
const headers = new Headers(this.headers);
|
||||||
for (const [key, value] of Object.entries(init.headers ?? {})) headers.set(key, value);
|
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("; "));
|
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();
|
||||||
for (const cookie of response.headers.getSetCookie?.() ?? []) {
|
const timeout = setTimeout(() => controller.abort(), XIAOMI_REQUEST_TIMEOUT_MS);
|
||||||
const pair = cookie.split(";", 1)[0] ?? "";
|
const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal;
|
||||||
const index = pair.indexOf("=");
|
try {
|
||||||
if (index > 0) this.cookies.set(pair.slice(0, index), pair.slice(index + 1));
|
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<string, string> {
|
get cookiesSnapshot(): Record<string, string> {
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import { gunzipSync, inflateSync } from "node:zlib";
|
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];
|
const VALID_TYPES = [0, 1, 2, 6, 7, 8, 9, 10, 3, 4, 5];
|
||||||
export const FDS_SLEEP_DAILY_TYPE = 8;
|
export const FDS_SLEEP_DAILY_TYPE = 8;
|
||||||
@@ -144,7 +144,7 @@ export async function downloadAndDecryptSleepDetails(
|
|||||||
if (!fileInfo) return null;
|
if (!fileInfo) return null;
|
||||||
const url = typeof fileInfo.url === "string" ? fileInfo.url : "";
|
const url = typeof fileInfo.url === "string" ? fileInfo.url : "";
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url, { signal: AbortSignal.timeout(XIAOMI_REQUEST_TIMEOUT_MS) });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
logFn(`FDS download returned HTTP ${response.status}`);
|
logFn(`FDS download returned HTTP ${response.status}`);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -31,4 +31,8 @@ describe("loadConfig", () => {
|
|||||||
const config = loadConfig({ BOT_MODE: "http-only", ENABLE_FDS_SLEEP_DETAILS: "false" });
|
const config = loadConfig({ BOT_MODE: "http-only", ENABLE_FDS_SLEEP_DETAILS: "false" });
|
||||||
expect(config.ENABLE_FDS_SLEEP_DETAILS).toBe(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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
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", () => {
|
describe("bot formatting", () => {
|
||||||
test("escapes Telegram HTML and formats health values", () => {
|
test("escapes Telegram HTML and formats health values", () => {
|
||||||
@@ -7,4 +7,10 @@ describe("bot formatting", () => {
|
|||||||
expect(minutes(396)).toBe("6 h 36 min");
|
expect(minutes(396)).toBe("6 h 36 min");
|
||||||
expect(stepBar(5000)).toContain("50%");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
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 { join } from "node:path";
|
||||||
import { withExclusiveFileLock } from "../src/storage/secure-files.js";
|
import { withExclusiveFileLock } from "../src/storage/secure-files.js";
|
||||||
|
|
||||||
@@ -11,4 +11,13 @@ describe("file locks", () => {
|
|||||||
await expect(withExclusiveFileLock(path, async () => "second")).resolves.toBe("second");
|
await expect(withExclusiveFileLock(path, async () => "second")).resolves.toBe("second");
|
||||||
rmSync(directory, { recursive: true, force: true });
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user