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