feat(gateway): add models.list

This commit is contained in:
Peter Steinberger
2025-12-20 23:23:59 +01:00
parent dbc9b00de5
commit 817abd8b5f
5 changed files with 765 additions and 38 deletions

View File

@@ -42,6 +42,8 @@ import {
GatewayFrameSchema,
type HelloOk,
HelloOkSchema,
type ModelsListParams,
ModelsListParamsSchema,
type NodeDescribeParams,
NodeDescribeParamsSchema,
type NodeInvokeParams,
@@ -62,6 +64,8 @@ import {
type PresenceEntry,
PresenceEntrySchema,
ProtocolSchemas,
type ProvidersStatusParams,
ProvidersStatusParamsSchema,
type RequestFrame,
RequestFrameSchema,
type ResponseFrame,
@@ -87,6 +91,10 @@ import {
TickEventSchema,
type WakeParams,
WakeParamsSchema,
type WebLoginStartParams,
WebLoginStartParamsSchema,
type WebLoginWaitParams,
WebLoginWaitParamsSchema,
} from "./schema.js";
const ajv = new (
@@ -141,6 +149,12 @@ export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
ConfigSetParamsSchema,
);
export const validateProvidersStatusParams = ajv.compile<ProvidersStatusParams>(
ProvidersStatusParamsSchema,
);
export const validateModelsListParams = ajv.compile<ModelsListParams>(
ModelsListParamsSchema,
);
export const validateSkillsStatusParams = ajv.compile<SkillsStatusParams>(
SkillsStatusParamsSchema,
);
@@ -173,6 +187,12 @@ export const validateChatAbortParams = ajv.compile<ChatAbortParams>(
ChatAbortParamsSchema,
);
export const validateChatEvent = ajv.compile(ChatEventSchema);
export const validateWebLoginStartParams = ajv.compile<WebLoginStartParams>(
WebLoginStartParamsSchema,
);
export const validateWebLoginWaitParams = ajv.compile<WebLoginWaitParams>(
WebLoginWaitParamsSchema,
);
export function formatValidationErrors(
errors: ErrorObject[] | null | undefined,
@@ -208,6 +228,10 @@ export {
SessionsPatchParamsSchema,
ConfigGetParamsSchema,
ConfigSetParamsSchema,
ProvidersStatusParamsSchema,
WebLoginStartParamsSchema,
WebLoginWaitParamsSchema,
ModelsListParamsSchema,
SkillsStatusParamsSchema,
SkillsInstallParamsSchema,
SkillsUpdateParamsSchema,
@@ -250,6 +274,9 @@ export type {
NodePairApproveParams,
ConfigGetParams,
ConfigSetParams,
ProvidersStatusParams,
WebLoginStartParams,
WebLoginWaitParams,
SkillsStatusParams,
SkillsInstallParams,
SkillsUpdateParams,

View File

@@ -305,6 +305,52 @@ export const ConfigSetParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ProvidersStatusParamsSchema = Type.Object(
{
probe: Type.Optional(Type.Boolean()),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
);
export const WebLoginStartParamsSchema = Type.Object(
{
force: Type.Optional(Type.Boolean()),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
verbose: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
export const WebLoginWaitParamsSchema = Type.Object(
{
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
);
export const ModelChoiceSchema = Type.Object(
{
id: NonEmptyString,
name: NonEmptyString,
provider: NonEmptyString,
contextWindow: Type.Optional(Type.Integer({ minimum: 1 })),
},
{ additionalProperties: false },
);
export const ModelsListParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const ModelsListResultSchema = Type.Object(
{
models: Type.Array(ModelChoiceSchema),
},
{ additionalProperties: false },
);
export const SkillsStatusParamsSchema = Type.Object(
{},
{ additionalProperties: false },
@@ -583,6 +629,12 @@ export const ProtocolSchemas: Record<string, TSchema> = {
SessionsPatchParams: SessionsPatchParamsSchema,
ConfigGetParams: ConfigGetParamsSchema,
ConfigSetParams: ConfigSetParamsSchema,
ProvidersStatusParams: ProvidersStatusParamsSchema,
WebLoginStartParams: WebLoginStartParamsSchema,
WebLoginWaitParams: WebLoginWaitParamsSchema,
ModelChoice: ModelChoiceSchema,
ModelsListParams: ModelsListParamsSchema,
ModelsListResult: ModelsListResultSchema,
SkillsStatusParams: SkillsStatusParamsSchema,
SkillsInstallParams: SkillsInstallParamsSchema,
SkillsUpdateParams: SkillsUpdateParamsSchema,
@@ -629,6 +681,12 @@ export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>;
export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>;
export type WebLoginWaitParams = Static<typeof WebLoginWaitParamsSchema>;
export type ModelChoice = Static<typeof ModelChoiceSchema>;
export type ModelsListParams = Static<typeof ModelsListParamsSchema>;
export type ModelsListResult = Static<typeof ModelsListResultSchema>;
export type SkillsStatusParams = Static<typeof SkillsStatusParamsSchema>;
export type SkillsInstallParams = Static<typeof SkillsInstallParamsSchema>;
export type SkillsUpdateParams = Static<typeof SkillsUpdateParamsSchema>;

View File

@@ -101,11 +101,17 @@ import { runExec } from "../process/exec.js";
import { monitorWebProvider, webAuthExists } from "../providers/web/index.js";
import { defaultRuntime } from "../runtime.js";
import { monitorTelegramProvider } from "../telegram/monitor.js";
import { probeTelegram, type TelegramProbe } from "../telegram/probe.js";
import { sendMessageTelegram } from "../telegram/send.js";
import { normalizeE164, resolveUserPath } from "../utils.js";
import { setHeartbeatsEnabled } from "../web/auto-reply.js";
import {
setHeartbeatsEnabled,
type WebProviderStatus,
} from "../web/auto-reply.js";
import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js";
import { sendMessageWhatsApp } from "../web/outbound.js";
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
import { getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../web/session.js";
import { buildMessageWithAttachments } from "./chat-attachments.js";
import { handleControlUiHttpRequest } from "./control-ui.js";
@@ -156,6 +162,65 @@ async function startBrowserControlServerIfEnabled(): Promise<void> {
await mod.startBrowserControlServerFromConfig(defaultRuntime);
}
type GatewayModelChoice = {
id: string;
name: string;
provider: string;
contextWindow?: number;
};
let modelCatalogPromise: Promise<GatewayModelChoice[]> | null = null;
// Test-only escape hatch: model catalog is cached at module scope for the
// process lifetime, which is fine for the real gateway daemon, but makes
// isolated unit tests harder. Keep this intentionally obscure.
export function __resetModelCatalogCacheForTest() {
modelCatalogPromise = null;
}
async function loadGatewayModelCatalog(): Promise<GatewayModelChoice[]> {
if (modelCatalogPromise) return modelCatalogPromise;
modelCatalogPromise = (async () => {
const piAi = (await import("@mariozechner/pi-ai")) as unknown as {
getProviders: () => string[];
getModels: (provider: string) => Array<{
id: string;
name?: string;
contextWindow?: number;
}>;
};
const models: GatewayModelChoice[] = [];
for (const provider of piAi.getProviders()) {
let entries: Array<{ id: string; name?: string; contextWindow?: number }>;
try {
entries = piAi.getModels(provider);
} catch {
continue;
}
for (const entry of entries) {
const id = String(entry?.id ?? "").trim();
if (!id) continue;
const name = String(entry?.name ?? id).trim() || id;
const contextWindow =
typeof entry?.contextWindow === "number" && entry.contextWindow > 0
? entry.contextWindow
: undefined;
models.push({ id, name, provider, contextWindow });
}
}
return models.sort((a, b) => {
const p = a.provider.localeCompare(b.provider);
if (p !== 0) return p;
return a.name.localeCompare(b.name);
});
})();
return modelCatalogPromise;
}
import {
type ConnectParams,
ErrorCodes,
@@ -181,6 +246,7 @@ import {
validateCronRunsParams,
validateCronStatusParams,
validateCronUpdateParams,
validateModelsListParams,
validateNodeDescribeParams,
validateNodeInvokeParams,
validateNodeListParams,
@@ -189,6 +255,7 @@ import {
validateNodePairRejectParams,
validateNodePairRequestParams,
validateNodePairVerifyParams,
validateProvidersStatusParams,
validateRequestFrame,
validateSendParams,
validateSessionsListParams,
@@ -197,6 +264,8 @@ import {
validateSkillsStatusParams,
validateSkillsUpdateParams,
validateWakeParams,
validateWebLoginStartParams,
validateWebLoginWaitParams,
} from "./protocol/index.js";
import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js";
@@ -267,9 +336,11 @@ type SessionsPatchResult = {
const METHODS = [
"health",
"providers.status",
"status",
"config.get",
"config.set",
"models.list",
"skills.status",
"skills.install",
"skills.update",
@@ -299,6 +370,10 @@ const METHODS = [
"system-event",
"send",
"agent",
"web.login.start",
"web.login.wait",
"web.logout",
"telegram.logout",
// WebChat WebSocket-native chat methods
"chat.history",
"chat.abort",
@@ -1113,8 +1188,33 @@ export async function startGatewayServer(
wss.emit("connection", ws, req);
});
});
const providerAbort = new AbortController();
const providerTasks: Array<Promise<unknown>> = [];
let whatsappAbort: AbortController | null = null;
let telegramAbort: AbortController | null = null;
let whatsappTask: Promise<unknown> | null = null;
let telegramTask: Promise<unknown> | null = null;
let whatsappRuntime: WebProviderStatus = {
running: false,
connected: false,
reconnectAttempts: 0,
lastConnectedAt: null,
lastDisconnect: null,
lastMessageAt: null,
lastEventAt: null,
lastError: null,
};
let telegramRuntime: {
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
mode?: "webhook" | "polling" | null;
} = {
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
mode: null,
};
const clients = new Set<Client>();
let seq = 0;
// Track per-run sequence to detect out-of-order/lost agent events.
@@ -1185,49 +1285,150 @@ export async function startGatewayServer(
},
});
const startProviders = async () => {
const cfg = loadConfig();
const telegramToken =
process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken ?? "";
const updateWhatsAppStatus = (next: WebProviderStatus) => {
whatsappRuntime = next;
};
if (await webAuthExists()) {
defaultRuntime.log("gateway: starting WhatsApp Web provider");
providerTasks.push(
monitorWebProvider(
isVerbose(),
undefined,
true,
undefined,
defaultRuntime,
providerAbort.signal,
).catch((err) => logError(`web provider exited: ${formatError(err)}`)),
);
} else {
const startWhatsAppProvider = async () => {
if (whatsappTask) return;
if (!(await webAuthExists())) {
whatsappRuntime = {
...whatsappRuntime,
running: false,
connected: false,
lastError: "not linked",
};
defaultRuntime.log(
"gateway: skipping WhatsApp Web provider (no linked session)",
);
return;
}
defaultRuntime.log("gateway: starting WhatsApp Web provider");
whatsappAbort = new AbortController();
whatsappRuntime = {
...whatsappRuntime,
running: true,
connected: false,
lastError: null,
};
const task = monitorWebProvider(
isVerbose(),
undefined,
true,
undefined,
defaultRuntime,
whatsappAbort.signal,
{ statusSink: updateWhatsAppStatus },
)
.catch((err) => {
whatsappRuntime = {
...whatsappRuntime,
lastError: formatError(err),
};
logError(`web provider exited: ${formatError(err)}`);
})
.finally(() => {
whatsappAbort = null;
whatsappTask = null;
whatsappRuntime = {
...whatsappRuntime,
running: false,
connected: false,
};
});
whatsappTask = task;
};
if (telegramToken.trim().length > 0) {
defaultRuntime.log("gateway: starting Telegram provider");
providerTasks.push(
monitorTelegramProvider({
token: telegramToken.trim(),
runtime: defaultRuntime,
abortSignal: providerAbort.signal,
useWebhook: Boolean(cfg.telegram?.webhookUrl),
webhookUrl: cfg.telegram?.webhookUrl,
webhookSecret: cfg.telegram?.webhookSecret,
webhookPath: cfg.telegram?.webhookPath,
}).catch((err) =>
logError(`telegram provider exited: ${formatError(err)}`),
),
);
} else {
const stopWhatsAppProvider = async () => {
if (!whatsappAbort && !whatsappTask) return;
whatsappAbort?.abort();
try {
await whatsappTask;
} catch {
// ignore
}
whatsappAbort = null;
whatsappTask = null;
whatsappRuntime = {
...whatsappRuntime,
running: false,
connected: false,
};
};
const startTelegramProvider = async () => {
if (telegramTask) return;
const cfg = loadConfig();
const telegramToken =
process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken ?? "";
if (!telegramToken.trim()) {
telegramRuntime = {
...telegramRuntime,
running: false,
lastError: "not configured",
};
defaultRuntime.log(
"gateway: skipping Telegram provider (no TELEGRAM_BOT_TOKEN/config)",
);
return;
}
defaultRuntime.log("gateway: starting Telegram provider");
telegramAbort = new AbortController();
telegramRuntime = {
...telegramRuntime,
running: true,
lastStartAt: Date.now(),
lastError: null,
mode: cfg.telegram?.webhookUrl ? "webhook" : "polling",
};
const task = monitorTelegramProvider({
token: telegramToken.trim(),
runtime: defaultRuntime,
abortSignal: telegramAbort.signal,
useWebhook: Boolean(cfg.telegram?.webhookUrl),
webhookUrl: cfg.telegram?.webhookUrl,
webhookSecret: cfg.telegram?.webhookSecret,
webhookPath: cfg.telegram?.webhookPath,
})
.catch((err) => {
telegramRuntime = {
...telegramRuntime,
lastError: formatError(err),
};
logError(`telegram provider exited: ${formatError(err)}`);
})
.finally(() => {
telegramAbort = null;
telegramTask = null;
telegramRuntime = {
...telegramRuntime,
running: false,
lastStopAt: Date.now(),
};
});
telegramTask = task;
};
const stopTelegramProvider = async () => {
if (!telegramAbort && !telegramTask) return;
telegramAbort?.abort();
try {
await telegramTask;
} catch {
// ignore
}
telegramAbort = null;
telegramTask = null;
telegramRuntime = {
...telegramRuntime,
running: false,
lastStopAt: Date.now(),
};
};
const startProviders = async () => {
await startWhatsAppProvider();
await startTelegramProvider();
};
const broadcast = (
@@ -1539,6 +1740,20 @@ export async function startGatewayServer(
}),
};
}
case "models.list": {
const params = parseParams();
if (!validateModelsListParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid models.list params: ${formatValidationErrors(validateModelsListParams.errors)}`,
},
};
}
const models = await loadGatewayModelCatalog();
return { ok: true, payloadJSON: JSON.stringify({ models }) };
}
case "sessions.list": {
const params = parseParams();
if (!validateSessionsListParams(params)) {
@@ -2771,6 +2986,84 @@ export async function startGatewayServer(
}
break;
}
case "providers.status": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateProvidersStatusParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid providers.status params: ${formatValidationErrors(validateProvidersStatusParams.errors)}`,
),
);
break;
}
const probe = (params as { probe?: boolean }).probe === true;
const timeoutMsRaw = (params as { timeoutMs?: unknown })
.timeoutMs;
const timeoutMs =
typeof timeoutMsRaw === "number"
? Math.max(1000, timeoutMsRaw)
: 10_000;
const cfg = loadConfig();
const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim();
const configToken = cfg.telegram?.botToken?.trim();
const telegramToken = envToken || configToken || "";
const tokenSource = envToken
? "env"
: configToken
? "config"
: "none";
let telegramProbe: TelegramProbe | undefined;
let lastProbeAt: number | null = null;
if (probe && telegramToken) {
telegramProbe = await probeTelegram(
telegramToken,
timeoutMs,
cfg.telegram?.proxy,
);
lastProbeAt = Date.now();
}
const linked = await webAuthExists();
const authAgeMs = getWebAuthAgeMs();
const self = readWebSelfId();
respond(
true,
{
ts: Date.now(),
whatsapp: {
configured: linked,
linked,
authAgeMs,
self,
running: whatsappRuntime.running,
connected: whatsappRuntime.connected,
lastConnectedAt: whatsappRuntime.lastConnectedAt ?? null,
lastDisconnect: whatsappRuntime.lastDisconnect ?? null,
reconnectAttempts: whatsappRuntime.reconnectAttempts,
lastMessageAt: whatsappRuntime.lastMessageAt ?? null,
lastEventAt: whatsappRuntime.lastEventAt ?? null,
lastError: whatsappRuntime.lastError ?? null,
},
telegram: {
configured: Boolean(telegramToken),
tokenSource,
running: telegramRuntime.running,
mode: telegramRuntime.mode ?? null,
lastStartAt: telegramRuntime.lastStartAt ?? null,
lastStopAt: telegramRuntime.lastStopAt ?? null,
lastError: telegramRuntime.lastError ?? null,
probe: telegramProbe,
lastProbeAt,
},
},
undefined,
);
break;
}
case "chat.history": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateChatHistoryParams(params)) {
@@ -3195,6 +3488,164 @@ export async function startGatewayServer(
respond(true, status, undefined);
break;
}
case "web.login.start": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateWebLoginStartParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid web.login.start params: ${formatValidationErrors(validateWebLoginStartParams.errors)}`,
),
);
break;
}
try {
await stopWhatsAppProvider();
const result = await startWebLoginWithQr({
force: Boolean((params as { force?: boolean }).force),
timeoutMs:
typeof (params as { timeoutMs?: unknown }).timeoutMs ===
"number"
? (params as { timeoutMs?: number }).timeoutMs
: undefined,
verbose: Boolean((params as { verbose?: boolean }).verbose),
});
respond(true, result, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
}
break;
}
case "web.login.wait": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateWebLoginWaitParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid web.login.wait params: ${formatValidationErrors(validateWebLoginWaitParams.errors)}`,
),
);
break;
}
try {
const result = await waitForWebLogin({
timeoutMs:
typeof (params as { timeoutMs?: unknown }).timeoutMs ===
"number"
? (params as { timeoutMs?: number }).timeoutMs
: undefined,
});
if (result.connected) {
await startWhatsAppProvider();
}
respond(true, result, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
}
break;
}
case "web.logout": {
try {
await stopWhatsAppProvider();
const cleared = await logoutWeb(defaultRuntime);
whatsappRuntime = {
...whatsappRuntime,
running: false,
connected: false,
lastError: cleared ? "logged out" : whatsappRuntime.lastError,
};
respond(true, { cleared }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
}
break;
}
case "telegram.logout": {
try {
await stopTelegramProvider();
const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"config invalid; fix it before logging out",
),
);
break;
}
const cfg = snapshot.config ?? {};
const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim() ?? "";
const hadToken = Boolean(cfg.telegram?.botToken);
const nextTelegram = cfg.telegram
? { ...cfg.telegram }
: undefined;
if (nextTelegram) {
delete nextTelegram.botToken;
}
const nextCfg = { ...cfg } as ClawdisConfig;
if (nextTelegram && Object.keys(nextTelegram).length > 0) {
nextCfg.telegram = nextTelegram;
} else {
delete nextCfg.telegram;
}
await writeConfigFile(nextCfg);
respond(
true,
{ cleared: hadToken, envToken: Boolean(envToken) },
undefined,
);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
}
break;
}
case "models.list": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateModelsListParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid models.list params: ${formatValidationErrors(validateModelsListParams.errors)}`,
),
);
break;
}
try {
const models = await loadGatewayModelCatalog();
respond(true, { models }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, String(err)),
);
}
break;
}
case "config.get": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateConfigGetParams(params)) {
@@ -4445,7 +4896,8 @@ export async function startGatewayServer(
/* ignore */
}
}
providerAbort.abort();
await stopWhatsAppProvider();
await stopTelegramProvider();
cron.stop();
broadcast("shutdown", {
reason: "gateway stopping",
@@ -4481,7 +4933,9 @@ export async function startGatewayServer(
if (stopBrowserControlServerIfStarted) {
await stopBrowserControlServerIfStarted().catch(() => {});
}
await Promise.allSettled(providerTasks);
await Promise.allSettled(
[whatsappTask, telegramTask].filter(Boolean) as Array<Promise<unknown>>,
);
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve, reject) =>
httpServer.close((err) => (err ? reject(err) : resolve())),

96
src/telegram/probe.ts Normal file
View File

@@ -0,0 +1,96 @@
import { makeProxyFetch } from "./proxy.js";
const TELEGRAM_API_BASE = "https://api.telegram.org";
export 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 };
};
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);
}
}
export 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 probe
}
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,
};
}
}