Mac: launch gateway and add relay installer

This commit is contained in:
Peter Steinberger
2025-12-09 16:15:53 +00:00
parent 96be7c8990
commit e40f9c9730
12 changed files with 616 additions and 73 deletions

View File

@@ -397,18 +397,39 @@ Examples:
.command("status")
.description("Show web session health and recent session recipients")
.option("--json", "Output JSON instead of text", false)
.option("--deep", "Probe providers (WA connect + Telegram API)", false)
.option("--timeout <ms>", "Probe timeout in milliseconds", "10000")
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
Examples:
clawdis status # show linked account + session store summary
clawdis status --json # machine-readable output`,
clawdis status --json # machine-readable output
clawdis status --deep # run provider probes (WA + Telegram)
clawdis status --deep --timeout 5000 # tighten probe timeout`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const timeout = opts.timeout
? Number.parseInt(String(opts.timeout), 10)
: undefined;
if (timeout !== undefined && (Number.isNaN(timeout) || timeout <= 0)) {
defaultRuntime.error(
"--timeout must be a positive integer (milliseconds)",
);
defaultRuntime.exit(1);
return;
}
try {
await statusCommand(opts, defaultRuntime);
await statusCommand(
{
json: Boolean(opts.json),
deep: Boolean(opts.deep),
timeoutMs: timeout,
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);

View File

@@ -21,6 +21,9 @@ vi.mock("../config/sessions.js", () => ({
const waitForWaConnection = vi.fn();
const webAuthExists = vi.fn();
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
vi.mock("../web/session.js", () => ({
createWaSocket: vi.fn(async () => ({
@@ -41,11 +44,25 @@ vi.mock("../web/reconnect.js", () => ({
describe("healthCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.TELEGRAM_BOT_TOKEN;
fetchMock.mockReset();
});
it("outputs JSON when linked and connect succeeds", async () => {
webAuthExists.mockResolvedValue(true);
waitForWaConnection.mockResolvedValue(undefined);
process.env.TELEGRAM_BOT_TOKEN = "123:abc";
fetchMock
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ ok: true, result: { id: 1, username: "bot" } }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ ok: true, result: { url: "https://hook" } }),
});
await healthCommand({ json: true, timeoutMs: 5000 }, runtime as never);
@@ -54,6 +71,8 @@ describe("healthCommand", () => {
const parsed = JSON.parse(logged);
expect(parsed.web.linked).toBe(true);
expect(parsed.web.connect.ok).toBe(true);
expect(parsed.telegram.configured).toBe(true);
expect(parsed.telegram.probe.ok).toBe(true);
expect(parsed.sessions.count).toBe(1);
});
@@ -75,4 +94,24 @@ describe("healthCommand", () => {
expect(parsed.web.connect.ok).toBe(false);
expect(parsed.web.connect.status).toBe(440);
});
it("exits non-zero when telegram probe fails", async () => {
webAuthExists.mockResolvedValue(true);
waitForWaConnection.mockResolvedValue(undefined);
process.env.TELEGRAM_BOT_TOKEN = "123:abc";
fetchMock.mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ ok: false, description: "unauthorized" }),
});
await healthCommand({ json: true }, runtime as never);
expect(runtime.exit).toHaveBeenCalledWith(1);
const logged = runtime.log.mock.calls[0][0] as string;
const parsed = JSON.parse(logged);
expect(parsed.telegram.configured).toBe(true);
expect(parsed.telegram.probe.ok).toBe(false);
expect(parsed.telegram.probe.status).toBe(401);
});
});

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { info } from "../globals.js";
import { makeProxyFetch } from "../telegram/proxy.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
import {
@@ -22,6 +23,15 @@ type HealthConnect = {
elapsedMs: number;
};
type TelegramProbe = {
ok: boolean;
status?: number | null;
error?: string | null;
elapsedMs: number;
bot?: { id?: number | null; username?: string | null };
webhook?: { url?: string | null; hasCustomCert?: boolean | null };
};
export type HealthSummary = {
ts: number;
durationMs: number;
@@ -30,6 +40,10 @@ export type HealthSummary = {
authAgeMs: number | null;
connect?: HealthConnect;
};
telegram: {
configured: boolean;
probe?: TelegramProbe;
};
heartbeatSeconds: number;
sessions: {
path: string;
@@ -44,6 +58,7 @@ export type HealthSummary = {
};
const DEFAULT_TIMEOUT_MS = 10_000;
const TELEGRAM_API_BASE = "https://api.telegram.org";
async function probeWebConnect(timeoutMs: number): Promise<HealthConnect> {
const started = Date.now();
@@ -77,6 +92,93 @@ async function probeWebConnect(timeoutMs: number): Promise<HealthConnect> {
}
}
async function fetchWithTimeout(
url: string,
timeoutMs: number,
fetcher: typeof fetch,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetcher(url, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
async function probeTelegram(
token: string,
timeoutMs: number,
proxyUrl?: string,
): Promise<TelegramProbe> {
const started = Date.now();
const fetcher = proxyUrl ? makeProxyFetch(proxyUrl) : fetch;
const base = `${TELEGRAM_API_BASE}/bot${token}`;
const result: TelegramProbe = {
ok: false,
status: null,
error: null,
elapsedMs: 0,
};
try {
const meRes = await fetchWithTimeout(`${base}/getMe`, timeoutMs, fetcher);
const meJson = (await meRes.json()) as {
ok?: boolean;
description?: string;
result?: { id?: number; username?: string };
};
if (!meRes.ok || !meJson?.ok) {
result.status = meRes.status;
result.error = meJson?.description ?? `getMe failed (${meRes.status})`;
return { ...result, elapsedMs: Date.now() - started };
}
result.bot = {
id: meJson.result?.id ?? null,
username: meJson.result?.username ?? null,
};
// Try to fetch webhook info, but don't fail health if it errors
try {
const webhookRes = await fetchWithTimeout(
`${base}/getWebhookInfo`,
timeoutMs,
fetcher,
);
const webhookJson = (await webhookRes.json()) as {
ok?: boolean;
result?: {
url?: string;
has_custom_certificate?: boolean;
};
};
if (webhookRes.ok && webhookJson?.ok) {
result.webhook = {
url: webhookJson.result?.url ?? null,
hasCustomCert: webhookJson.result?.has_custom_certificate ?? null,
};
}
} catch {
// ignore webhook errors for health
}
result.ok = true;
result.status = null;
result.error = null;
result.elapsedMs = Date.now() - started;
return result;
} catch (err) {
return {
...result,
status: err instanceof Response ? err.status : result.status,
error: err instanceof Error ? err.message : String(err),
elapsedMs: Date.now() - started,
};
}
}
export async function getHealthSnapshot(
timeoutMs?: number,
): Promise<HealthSummary> {
@@ -103,10 +205,19 @@ export async function getHealthSnapshot(
const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS);
const connect = linked ? await probeWebConnect(cappedTimeout) : undefined;
const telegramToken =
process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken ?? "";
const telegramConfigured = telegramToken.trim().length > 0;
const telegramProxy = cfg.telegram?.proxy;
const telegramProbe = telegramConfigured
? await probeTelegram(telegramToken.trim(), cappedTimeout, telegramProxy)
: undefined;
const summary: HealthSummary = {
ts: Date.now(),
durationMs: Date.now() - start,
web: { linked, authAgeMs, connect },
telegram: { configured: telegramConfigured, probe: telegramProbe },
heartbeatSeconds,
sessions: {
path: storePath,
@@ -125,7 +236,11 @@ export async function healthCommand(
) {
const summary = await getHealthSnapshot(opts.timeoutMs);
const fatal =
!summary.web.linked || (summary.web.connect && !summary.web.connect.ok);
!summary.web.linked ||
(summary.web.connect && !summary.web.connect.ok) ||
(summary.telegram.configured &&
summary.telegram.probe &&
!summary.telegram.probe.ok);
if (opts.json) {
runtime.log(JSON.stringify(summary, null, 2));
@@ -147,6 +262,19 @@ export async function healthCommand(
(summary.web.connect.error ? ` - ${summary.web.connect.error}` : ""),
);
}
const tgLabel = summary.telegram.configured
? summary.telegram.probe?.ok
? info(
`Telegram: ok${summary.telegram.probe.bot?.username ? ` (@${summary.telegram.probe.bot.username})` : ""} (${summary.telegram.probe.elapsedMs}ms)` +
(summary.telegram.probe.webhook?.url
? ` - webhook ${summary.telegram.probe.webhook.url}`
: ""),
)
: `Telegram: failed (${summary.telegram.probe?.status ?? "unknown"})${summary.telegram.probe?.error ? ` - ${summary.telegram.probe.error}` : ""}`
: "Telegram: not configured";
runtime.log(tgLabel);
runtime.log(info(`Heartbeat interval: ${summary.heartbeatSeconds}s`));
runtime.log(
info(

View File

@@ -7,6 +7,7 @@ import {
type SessionEntry,
} from "../config/sessions.js";
import { info } from "../globals.js";
import { getHealthSnapshot, type HealthSummary } from "./health.js";
import { buildProviderSummary } from "../infra/provider-summary.js";
import { peekSystemEvents } from "../infra/system-events.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -187,13 +188,22 @@ const buildFlags = (entry: SessionEntry): string[] => {
};
export async function statusCommand(
opts: { json?: boolean },
opts: { json?: boolean; deep?: boolean; timeoutMs?: number },
runtime: RuntimeEnv,
) {
const summary = await getStatusSummary();
const health: HealthSummary | undefined = opts.deep
? await getHealthSnapshot(opts.timeoutMs)
: undefined;
if (opts.json) {
runtime.log(JSON.stringify(summary, null, 2));
runtime.log(
JSON.stringify(
health ? { ...summary, health } : summary,
null,
2,
),
);
return;
}
@@ -204,6 +214,28 @@ export async function statusCommand(
logWebSelfId(runtime, true);
}
runtime.log(info(`System: ${summary.providerSummary}`));
if (health) {
const waLine = health.web.connect
? health.web.connect.ok
? info(`WA connect: ok (${health.web.connect.elapsedMs}ms)`)
: `WA connect: failed (${health.web.connect.status ?? "unknown"})${health.web.connect.error ? ` - ${health.web.connect.error}` : ""}`
: info("WA connect: skipped (not linked)");
runtime.log(waLine);
const tgLine = health.telegram.configured
? health.telegram.probe?.ok
? info(
`Telegram: ok${health.telegram.probe.bot?.username ? ` (@${health.telegram.probe.bot.username})` : ""} (${health.telegram.probe.elapsedMs}ms)` +
(health.telegram.probe.webhook?.url
? ` - webhook ${health.telegram.probe.webhook.url}`
: ""),
)
: `Telegram: failed (${health.telegram.probe?.status ?? "unknown"})${health.telegram.probe?.error ? ` - ${health.telegram.probe.error}` : ""}`
: info("Telegram: not configured");
runtime.log(tgLine);
} else {
runtime.log(info("Provider probes: skipped (use --deep)"));
}
if (summary.queuedSystemEvents.length > 0) {
const preview = summary.queuedSystemEvents.slice(0, 3).join(" | ");
runtime.log(