From 5d19afd4223980d9d925ed6721dd0421a055facb Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Sat, 20 Dec 2025 21:32:17 +0100 Subject: [PATCH] feat: improve health checks (telegram tokenFile + hints) --- apps/macos/Sources/Clawdis/HealthStore.swift | 39 +++++++++++++- src/commands/health.snapshot.test.ts | 55 ++++++++++++++++++++ src/commands/health.ts | 24 ++++++++- src/infra/provider-summary.ts | 7 ++- 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/apps/macos/Sources/Clawdis/HealthStore.swift b/apps/macos/Sources/Clawdis/HealthStore.swift index 262291824..cfd2ee014 100644 --- a/apps/macos/Sources/Clawdis/HealthStore.swift +++ b/apps/macos/Sources/Clawdis/HealthStore.swift @@ -5,6 +5,24 @@ import OSLog import SwiftUI struct HealthSnapshot: Codable, Sendable { + struct Telegram: Codable, Sendable { + struct Probe: Codable, Sendable { + struct Bot: Codable, Sendable { + let id: Int? + let username: String? + } + + let ok: Bool + let status: Int? + let error: String? + let elapsedMs: Double? + let bot: Bot? + } + + let configured: Bool + let probe: Probe? + } + struct Web: Codable, Sendable { struct Connect: Codable, Sendable { let ok: Bool @@ -30,9 +48,11 @@ struct HealthSnapshot: Codable, Sendable { let recent: [SessionInfo] } + let ok: Bool? let ts: Double let durationMs: Double let web: Web + let telegram: Telegram? let heartbeatSeconds: Int? let sessions: Sessions } @@ -112,12 +132,21 @@ final class HealthStore { } } + private static func isTelegramHealthy(_ snap: HealthSnapshot) -> Bool { + guard let tg = snap.telegram, tg.configured else { return false } + // If probe is missing, treat it as "configured but unknown health" (not a hard fail). + return tg.probe?.ok ?? true + } + var state: HealthState { if let error = self.lastError, !error.isEmpty { return .degraded(error) } guard let snap = self.snapshot else { return .unknown } - if !snap.web.linked { return .linkingNeeded } + if !snap.web.linked { + // WhatsApp Web linking is optional if Telegram is healthy; don't paint the whole app red. + return Self.isTelegramHealthy(snap) ? .degraded("Not linked") : .linkingNeeded + } if let connect = snap.web.connect, !connect.ok { let reason = connect.error ?? "connect failed" return .degraded(reason) @@ -129,7 +158,13 @@ final class HealthStore { if self.isRefreshing { return "Health check running…" } if let error = self.lastError { return "Health check failed: \(error)" } guard let snap = self.snapshot else { return "Health check pending" } - if !snap.web.linked { return "Not linked — run clawdis login" } + if !snap.web.linked { + if let tg = snap.telegram, tg.configured { + let tgLabel = (tg.probe?.ok ?? true) ? "Telegram ok" : "Telegram degraded" + return "\(tgLabel) · Not linked — run clawdis login" + } + return "Not linked — run clawdis login" + } let auth = snap.web.authAgeMs.map { msToAge($0) } ?? "unknown" if let connect = snap.web.connect, !connect.ok { let code = connect.status.map(String.init) ?? "?" diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 661ea382b..cdeb5a505 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -1,3 +1,7 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { afterEach, describe, expect, it, vi } from "vitest"; import type { HealthSummary } from "./health.js"; @@ -99,6 +103,57 @@ describe("getHealthSnapshot", () => { expect(calls.some((c) => c.includes("/getWebhookInfo"))).toBe(true); }); + it("treats telegram.tokenFile as configured", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-health-")); + const tokenFile = path.join(tmpDir, "telegram-token"); + fs.writeFileSync(tokenFile, "t-file\n", "utf-8"); + testConfig = { telegram: { tokenFile } }; + testStore = {}; + + const calls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + calls.push(url); + if (url.includes("/getMe")) { + return { + ok: true, + status: 200, + json: async () => ({ + ok: true, + result: { id: 1, username: "bot" }, + }), + } as unknown as Response; + } + if (url.includes("/getWebhookInfo")) { + return { + ok: true, + status: 200, + json: async () => ({ + ok: true, + result: { + url: "https://example.com/h", + has_custom_certificate: false, + }, + }), + } as unknown as Response; + } + return { + ok: false, + status: 404, + json: async () => ({ ok: false, description: "nope" }), + } as unknown as Response; + }), + ); + + const snap = await getHealthSnapshot(25); + expect(snap.telegram.configured).toBe(true); + expect(snap.telegram.probe?.ok).toBe(true); + expect(calls.some((c) => c.includes("bott-file/getMe"))).toBe(true); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + it("returns a structured telegram probe error when getMe fails", async () => { testConfig = { telegram: { botToken: "bad-token" } }; testStore = {}; diff --git a/src/commands/health.ts b/src/commands/health.ts index e294653f8..a01401512 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; + import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { type DiscordProbe, probeDiscord } from "../discord/probe.js"; @@ -53,6 +55,25 @@ export type HealthSummary = { const DEFAULT_TIMEOUT_MS = 10_000; +function loadTelegramToken(cfg: ReturnType): string { + const env = process.env.TELEGRAM_BOT_TOKEN?.trim(); + if (env) return env; + + const tokenFile = cfg.telegram?.tokenFile?.trim(); + if (tokenFile) { + try { + if (fs.existsSync(tokenFile)) { + const token = fs.readFileSync(tokenFile, "utf-8").trim(); + if (token) return token; + } + } catch { + // Ignore errors; health should be non-fatal. + } + } + + return cfg.telegram?.botToken?.trim() ?? ""; +} + export async function getHealthSnapshot( timeoutMs?: number, ): Promise { @@ -74,8 +95,7 @@ export async function getHealthSnapshot( const start = Date.now(); const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS); - const telegramToken = - process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken ?? ""; + const telegramToken = loadTelegramToken(cfg); const telegramConfigured = telegramToken.trim().length > 0; const telegramProxy = cfg.telegram?.proxy; const telegramProbe = telegramConfigured diff --git a/src/infra/provider-summary.ts b/src/infra/provider-summary.ts index 6fa163e7a..6f84fbe1f 100644 --- a/src/infra/provider-summary.ts +++ b/src/infra/provider-summary.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import chalk from "chalk"; import { type ClawdisConfig, loadConfig } from "../config/config.js"; import { normalizeE164 } from "../utils.js"; @@ -36,8 +37,12 @@ export async function buildProviderSummary( } else { const telegramToken = process.env.TELEGRAM_BOT_TOKEN ?? effective.telegram?.botToken; + const telegramTokenFile = effective.telegram?.tokenFile?.trim(); + const telegramConfigured = + Boolean(telegramToken) || + Boolean(telegramTokenFile ? fs.existsSync(telegramTokenFile) : false); lines.push( - telegramToken + telegramConfigured ? chalk.green("Telegram: configured") : chalk.cyan("Telegram: not configured"), );