feat: improve health checks (telegram tokenFile + hints)
This commit is contained in:
@@ -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) ?? "?"
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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<typeof loadConfig>): 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<HealthSummary> {
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user