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 run check
|
||||
bun run dev
|
||||
`
|
||||
```
|
||||
|
||||
The service exposes `/healthz` and `/readyz` on port `8080`.
|
||||
|
||||
|
||||
+4
-4
@@ -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
@@ -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
@@ -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('"', """);
|
||||
}
|
||||
|
||||
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
@@ -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> & {
|
||||
|
||||
+9
-3
@@ -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) {
|
||||
await bot.api.setWebhook(`${config.PUBLIC_WEBHOOK_URL}/telegram/webhook`, {
|
||||
secret_token: config.TELEGRAM_WEBHOOK_SECRET,
|
||||
});
|
||||
try {
|
||||
await bot.api.setWebhook(`${config.PUBLIC_WEBHOOK_URL}/telegram/webhook`, {
|
||||
secret_token: config.TELEGRAM_WEBHOOK_SECRET,
|
||||
});
|
||||
} catch (error) {
|
||||
log("error", "Failed to configure Telegram webhook", { error });
|
||||
await shutdown("TELEGRAM_WEBHOOK_SETUP_FAILED");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
log("info", "HTTP server listening", { address: `http://${config.BIND_HOST}:${config.PORT}`, mode: config.BOT_MODE });
|
||||
|
||||
@@ -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 {
|
||||
@@ -99,23 +101,27 @@ export async function withExclusiveFileLock<T>(path: string, action: () => Promi
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
stale = false;
|
||||
try {
|
||||
stale = Date.now() - statSync(lockDir).mtimeMs > STALE_LOCK_AGE_MS;
|
||||
} catch {
|
||||
stale = false;
|
||||
}
|
||||
}
|
||||
if (!stale) throw new LockUnavailable(`Lock is already held: ${path}`);
|
||||
try {
|
||||
unlinkSync(ownerPath);
|
||||
rmdirSync(lockDir);
|
||||
if (existsSync(path)) unlinkSync(path);
|
||||
mkdirSync(lockDir);
|
||||
} catch {
|
||||
throw new LockUnavailable(`Lock is already held: ${path}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
writeFileSync(path, `pid=${process.pid} time=${Math.floor(Date.now() / 1000)}\n`, "utf8");
|
||||
writeFileSync(ownerPath, `pid=${process.pid} time=${Math.floor(Date.now() / 1000)}\n`, "utf8");
|
||||
return await action();
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(path);
|
||||
unlinkSync(ownerPath);
|
||||
} catch {
|
||||
// Best effort cleanup.
|
||||
}
|
||||
|
||||
+104
-45
@@ -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,7 +407,8 @@ async function syncWorkouts(
|
||||
return record(raw);
|
||||
})();
|
||||
const recordWatermark = Number(entry.watermark ?? 0);
|
||||
watermark = Math.max(watermark, recordWatermark);
|
||||
if (Number.isSafeInteger(recordWatermark) && recordWatermark >= 0)
|
||||
watermark = Math.max(watermark, recordWatermark);
|
||||
const workoutId = String(entry.sid ?? entry.did ?? "");
|
||||
const start = Number(value.start_time ?? entry.time ?? 0);
|
||||
const duration = Number(value.duration ?? 0);
|
||||
@@ -385,6 +432,11 @@ async function syncWorkouts(
|
||||
if (resultRow.changes > 0) increment(counters, "workouts");
|
||||
}
|
||||
});
|
||||
if (hasMore && watermark <= previousWatermark) {
|
||||
log("warn", "Workout API returned no watermark progress", { userId: uid, watermark });
|
||||
break;
|
||||
}
|
||||
writeWorkoutWatermark(config, uid, watermark);
|
||||
}
|
||||
log("debug", "Workout watermark sync completed", { userId: uid });
|
||||
} catch (error) {
|
||||
@@ -392,6 +444,32 @@ async function syncWorkouts(
|
||||
}
|
||||
}
|
||||
|
||||
function workoutWatermarkKey(uid: number): string {
|
||||
return `workouts.watermark.${uid}`;
|
||||
}
|
||||
|
||||
function readWorkoutWatermark(config: AppConfig, uid: number): number {
|
||||
const database = openDatabase(config.botStateDbPath);
|
||||
try {
|
||||
migrateDatabase(database);
|
||||
const value = Number(getState(database, workoutWatermarkKey(uid)) ?? 0);
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
function writeWorkoutWatermark(config: AppConfig, uid: number, watermark: number): void {
|
||||
if (!Number.isSafeInteger(watermark) || watermark <= 0) return;
|
||||
const database = openDatabase(config.botStateDbPath);
|
||||
try {
|
||||
migrateDatabase(database);
|
||||
setState(database, workoutWatermarkKey(uid), String(watermark));
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncFds(
|
||||
client: MiHealthClient,
|
||||
relativeUid: number,
|
||||
@@ -438,53 +516,34 @@ function writeStatus(
|
||||
steps: StepData | undefined,
|
||||
heartRate: { timestamp: number; value: number } | undefined,
|
||||
sleep: SleepData | undefined,
|
||||
timeZone: string,
|
||||
): void {
|
||||
writeJsonAtomic(path, {
|
||||
last_sync: timestamp(),
|
||||
last_sync_time: formatEpoch(timestamp()),
|
||||
last_sync_time: formatEpoch(timestamp(), timeZone),
|
||||
today: steps
|
||||
? {
|
||||
date: new Date(steps.time * 1000).toISOString().slice(0, 10),
|
||||
date: dateInTimeZone(steps.time, timeZone),
|
||||
steps: steps.steps,
|
||||
calories: steps.calories,
|
||||
distance_m: steps.distance,
|
||||
}
|
||||
: null,
|
||||
latest_heart_rate: heartRate
|
||||
? { timestamp: heartRate.timestamp, time: formatEpoch(heartRate.timestamp), value: heartRate.value }
|
||||
? { timestamp: heartRate.timestamp, time: formatEpoch(heartRate.timestamp, timeZone), value: heartRate.value }
|
||||
: null,
|
||||
latest_sleep: sleep
|
||||
? {
|
||||
date: new Date(sleep.time * 1000).toISOString().slice(0, 10),
|
||||
date: dateInTimeZone(sleep.time, timeZone),
|
||||
light_sleep_min: sleep.sleep_light_duration,
|
||||
deep_sleep_min: sleep.sleep_deep_duration,
|
||||
rem_sleep_min: sleep.sleep_rem_duration,
|
||||
awake_min: sleep.sleep_awake_duration,
|
||||
total_sleep_min: sleep.total_duration || sleep.sleep_light_duration + sleep.sleep_deep_duration,
|
||||
sleep_score: sleep.sleep_score,
|
||||
start_time: formatEpoch(Math.min(...sleep.segment_details.map((segment) => segment.bedtime), 0)),
|
||||
end_time: formatEpoch(Math.max(...sleep.segment_details.map((segment) => segment.wake_up_time), 0)),
|
||||
start_time: formatEpoch(Math.min(...sleep.segment_details.map((segment) => segment.bedtime), 0), timeZone),
|
||||
end_time: formatEpoch(Math.max(...sleep.segment_details.map((segment) => segment.wake_up_time), 0), timeZone),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runSyncDaemon(config: AppConfig, signal: AbortSignal): Promise<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 LOGIN_UA =
|
||||
"Dalvik/2.1.0 (Linux; U; Android 12; V2284A Build/ab8c0d1.1) APP/mi.health APPV/353001 MK/VjIyODRB SDKV/5.3.0.release.68 CPN/com.mi.health PassportSDK/";
|
||||
export const XIAOMI_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
export class XiaomiError extends Error {}
|
||||
export class TokenExpiredError extends XiaomiError {}
|
||||
@@ -572,13 +573,25 @@ class XiaomiHttp {
|
||||
const headers = new Headers(this.headers);
|
||||
for (const [key, value] of Object.entries(init.headers ?? {})) headers.set(key, value);
|
||||
if (this.cookies.size) headers.set("cookie", [...this.cookies].map(([key, value]) => `${key}=${value}`).join("; "));
|
||||
const response = await fetch(url, { ...init, headers, redirect: "manual" });
|
||||
for (const cookie of response.headers.getSetCookie?.() ?? []) {
|
||||
const pair = cookie.split(";", 1)[0] ?? "";
|
||||
const index = pair.indexOf("=");
|
||||
if (index > 0) this.cookies.set(pair.slice(0, index), pair.slice(index + 1));
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), XIAOMI_REQUEST_TIMEOUT_MS);
|
||||
const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal;
|
||||
try {
|
||||
const response = await fetch(url, { ...init, headers, redirect: "manual", signal });
|
||||
for (const cookie of response.headers.getSetCookie?.() ?? []) {
|
||||
const pair = cookie.split(";", 1)[0] ?? "";
|
||||
const index = pair.indexOf("=");
|
||||
if (index > 0) this.cookies.set(pair.slice(0, index), pair.slice(index + 1));
|
||||
}
|
||||
const body = await response.arrayBuffer();
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
get cookiesSnapshot(): Record<string, string> {
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user