diff --git a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift index a547759c9c..1a8670d3c5 100644 --- a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift @@ -621,6 +621,98 @@ public struct ConfigSetParams: Codable { } } +public struct ProvidersStatusParams: Codable { + public let probe: Bool? + public let timeoutms: Int? + + public init( + probe: Bool?, + timeoutms: Int? + ) { + self.probe = probe + self.timeoutms = timeoutms + } + private enum CodingKeys: String, CodingKey { + case probe + case timeoutms = "timeoutMs" + } +} + +public struct WebLoginStartParams: Codable { + public let force: Bool? + public let timeoutms: Int? + public let verbose: Bool? + + public init( + force: Bool?, + timeoutms: Int?, + verbose: Bool? + ) { + self.force = force + self.timeoutms = timeoutms + self.verbose = verbose + } + private enum CodingKeys: String, CodingKey { + case force + case timeoutms = "timeoutMs" + case verbose + } +} + +public struct WebLoginWaitParams: Codable { + public let timeoutms: Int? + + public init( + timeoutms: Int? + ) { + self.timeoutms = timeoutms + } + private enum CodingKeys: String, CodingKey { + case timeoutms = "timeoutMs" + } +} + +public struct ModelChoice: Codable { + public let id: String + public let name: String + public let provider: String + public let contextwindow: Int? + + public init( + id: String, + name: String, + provider: String, + contextwindow: Int? + ) { + self.id = id + self.name = name + self.provider = provider + self.contextwindow = contextwindow + } + private enum CodingKeys: String, CodingKey { + case id + case name + case provider + case contextwindow = "contextWindow" + } +} + +public struct ModelsListParams: Codable { +} + +public struct ModelsListResult: Codable { + public let models: [ModelChoice] + + public init( + models: [ModelChoice] + ) { + self.models = models + } + private enum CodingKeys: String, CodingKey { + case models + } +} + public struct SkillsStatusParams: Codable { } diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 3a1b4d1623..4a0884c231 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -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( export const validateConfigSetParams = ajv.compile( ConfigSetParamsSchema, ); +export const validateProvidersStatusParams = ajv.compile( + ProvidersStatusParamsSchema, +); +export const validateModelsListParams = ajv.compile( + ModelsListParamsSchema, +); export const validateSkillsStatusParams = ajv.compile( SkillsStatusParamsSchema, ); @@ -173,6 +187,12 @@ export const validateChatAbortParams = ajv.compile( ChatAbortParamsSchema, ); export const validateChatEvent = ajv.compile(ChatEventSchema); +export const validateWebLoginStartParams = ajv.compile( + WebLoginStartParamsSchema, +); +export const validateWebLoginWaitParams = ajv.compile( + 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, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 7f214678a9..10964dac80 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -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 = { 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; export type SessionsPatchParams = Static; export type ConfigGetParams = Static; export type ConfigSetParams = Static; +export type ProvidersStatusParams = Static; +export type WebLoginStartParams = Static; +export type WebLoginWaitParams = Static; +export type ModelChoice = Static; +export type ModelsListParams = Static; +export type ModelsListResult = Static; export type SkillsStatusParams = Static; export type SkillsInstallParams = Static; export type SkillsUpdateParams = Static; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 17a0c9e87e..34fe50fd04 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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 { await mod.startBrowserControlServerFromConfig(defaultRuntime); } +type GatewayModelChoice = { + id: string; + name: string; + provider: string; + contextWindow?: number; +}; + +let modelCatalogPromise: Promise | 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 { + 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> = []; + let whatsappAbort: AbortController | null = null; + let telegramAbort: AbortController | null = null; + let whatsappTask: Promise | null = null; + let telegramTask: Promise | 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(); 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)) { @@ -2407,7 +2622,8 @@ export async function startGatewayServer( const remoteAddr = ( socket as WebSocket & { _socket?: { remoteAddress?: string } } )._socket?.remoteAddress; - const canvasHostPortForWs = canvasHostServer?.port ?? (canvasHost ? port : undefined); + const canvasHostPortForWs = + canvasHostServer?.port ?? (canvasHost ? port : undefined); const canvasHostOverride = bridgeHost && bridgeHost !== "0.0.0.0" && bridgeHost !== "::" ? bridgeHost @@ -2770,6 +2986,84 @@ export async function startGatewayServer( } break; } + case "providers.status": { + const params = (req.params ?? {}) as Record; + 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; if (!validateChatHistoryParams(params)) { @@ -3194,6 +3488,164 @@ export async function startGatewayServer( respond(true, status, undefined); break; } + case "web.login.start": { + const params = (req.params ?? {}) as Record; + 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; + 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; + 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; if (!validateConfigGetParams(params)) { @@ -4444,7 +4896,8 @@ export async function startGatewayServer( /* ignore */ } } - providerAbort.abort(); + await stopWhatsAppProvider(); + await stopTelegramProvider(); cron.stop(); broadcast("shutdown", { reason: "gateway stopping", @@ -4480,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>, + ); await new Promise((resolve) => wss.close(() => resolve())); await new Promise((resolve, reject) => httpServer.close((err) => (err ? reject(err) : resolve())), diff --git a/src/telegram/probe.ts b/src/telegram/probe.ts new file mode 100644 index 0000000000..b201adb918 --- /dev/null +++ b/src/telegram/probe.ts @@ -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 { + 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 { + 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, + }; + } +}