diff --git a/src/codex/service.ts b/src/codex/service.ts index d541f82..090f2c5 100644 --- a/src/codex/service.ts +++ b/src/codex/service.ts @@ -672,10 +672,10 @@ export class CodexService { ); } - public async account(): Promise { + public async account(refreshToken = false): Promise { return await this.#rpc.request({ method: "account/read", - params: { refreshToken: false }, + params: { refreshToken }, }); } diff --git a/src/index.ts b/src/index.ts index 4507a30..86b2277 100644 --- a/src/index.ts +++ b/src/index.ts @@ -156,24 +156,21 @@ export async function runWirebot(): Promise { resources.push(runtime); await runtime.start(); - // The Mini App authenticates through Telegram initData, so it only runs - // when the Telegram connector is configured. - let miniApp: MiniAppServer | undefined; - if (config.telegram !== undefined) { - miniApp = new MiniAppServer({ - host: config.host, - port: config.port, - botToken: config.telegram.botToken, - allowedUserIds: config.telegram.allowedUserIds, - configService, - runtime, - settings, - logger: logger.child({ component: "miniapp" }), - ...(config.assetsDirectory === undefined ? {} : { assetDirectory: config.assetsDirectory }), - }); - resources.push(miniApp); - await miniApp.start(); - } + // The HTTP server always provides health; Mini App routes additionally + // require Telegram initData when that connector is configured. + const miniApp = new MiniAppServer({ + host: config.host, + port: config.port, + codex, + ...(config.telegram === undefined ? {} : { telegramAuth: config.telegram }), + configService, + runtime, + settings, + logger: logger.child({ component: "miniapp" }), + ...(config.assetsDirectory === undefined ? {} : { assetDirectory: config.assetsDirectory }), + }); + resources.push(miniApp); + await miniApp.start(); let publicUrl = config.publicUrl; if (publicUrl === undefined && config.telegram !== undefined && config.tunnelMode === "auto") { @@ -231,7 +228,7 @@ export async function runWirebot(): Promise { workspace: config.workspace, logger: logger.child({ component: "scheduled-runs" }), }); - miniApp?.setScheduledRuns(scheduledRuns); + miniApp.setScheduledRuns(scheduledRuns); const bridge = new CodexBridge( codex, publicUrl, diff --git a/src/miniapp/server.ts b/src/miniapp/server.ts index 6d671cd..ede7449 100644 --- a/src/miniapp/server.ts +++ b/src/miniapp/server.ts @@ -16,9 +16,11 @@ import { } from "../codex/config-service.js"; import { CodexRpcError } from "../codex/rpc.js"; import type { CodexRuntimeService } from "../codex/runtime-service.js"; +import type { CodexService } from "../codex/service.js"; import { SkillBrowserError } from "../codex/skill-browser.js"; import type { ProviderReference } from "../core/channel.js"; import type { WirebotSettingsStore } from "../core/settings-store.js"; +import type { GetAccountResponse } from "../generated/codex/v2/GetAccountResponse.js"; import { BridgeError, errorMessage } from "../shared/errors.js"; import type { Logger } from "../shared/logger.js"; import { type TelegramInitDataUser, validateTelegramInitData } from "./auth.js"; @@ -52,8 +54,11 @@ const applyBankedResetSchema = z.strictObject({ export interface MiniAppServerOptions { readonly host: string; readonly port: number; - readonly botToken: string; - readonly allowedUserIds: ReadonlySet; + readonly telegramAuth?: { + readonly botToken: string; + readonly allowedUserIds: ReadonlySet; + }; + readonly codex: Pick; readonly configService: CodexConfigService; readonly runtime: MiniAppRuntimeController; readonly settings: WirebotSettingsStore; @@ -86,6 +91,9 @@ export class MiniAppServer { readonly #assetDirectory: string; readonly #assetCache = new Map(); #scheduledRuns: MiniAppSchedulesController | undefined; + #codexHealth: CodexHealth = "starting"; + #healthRefresh: Promise | undefined; + #healthTimer: NodeJS.Timeout | undefined; #started = false; public constructor(options: MiniAppServerOptions) { @@ -116,11 +124,13 @@ export class MiniAppServer { public async start(): Promise { if (this.#started) return; - await Promise.all([ - access(join(this.#assetDirectory, "index.html")), - access(join(this.#assetDirectory, "app.js")), - access(join(this.#assetDirectory, "app.css")), - ]); + if (this.options.telegramAuth !== undefined) { + await Promise.all([ + access(join(this.#assetDirectory, "index.html")), + access(join(this.#assetDirectory, "app.js")), + access(join(this.#assetDirectory, "app.css")), + ]); + } await new Promise((resolve, reject) => { const onError = (error: Error): void => { this.#server.off("listening", onListening); @@ -135,7 +145,10 @@ export class MiniAppServer { this.#server.listen(this.options.port, this.options.host); }); this.#started = true; - this.options.logger.info("Mini App HTTP server listening", { + this.refreshCodexHealth(); + this.#healthTimer = setInterval(() => this.refreshCodexHealth(), 30_000); + this.#healthTimer.unref(); + this.options.logger.info("Wirebot HTTP server listening", { host: this.options.host, port: this.options.port, }); @@ -143,6 +156,7 @@ export class MiniAppServer { public async stop(): Promise { if (!this.#started) return; + if (this.#healthTimer !== undefined) clearInterval(this.#healthTimer); await new Promise((resolve, reject) => { this.#server.close((error) => { if (error === undefined) resolve(); @@ -157,7 +171,7 @@ export class MiniAppServer { const url = new URL(request.url ?? "/", "http://localhost"); if (request.method === "GET" && url.pathname === "/healthz") { - this.sendJson(response, 200, { ok: true }); + this.sendJson(response, 200, { ok: true, codex: this.#codexHealth }); return; } @@ -326,7 +340,7 @@ export class MiniAppServer { } const asset = staticAssets.get(url.pathname); - if (asset !== undefined) { + if (asset !== undefined && this.options.telegramAuth !== undefined) { await this.sendAsset(response, request.method, asset[0], asset[1]); return; } @@ -335,17 +349,36 @@ export class MiniAppServer { } private authenticate(request: IncomingMessage): TelegramInitDataUser { + const telegram = this.options.telegramAuth; + if (telegram === undefined) throw new HttpError(404, "Not found"); const authorization = request.headers.authorization; if (authorization === undefined || !authorization.toLowerCase().startsWith("tma ")) { throw new BridgeError("Telegram authorization is required", "MINIAPP_UNAUTHORIZED"); } return validateTelegramInitData(authorization.slice(4), { - botToken: this.options.botToken, - allowedUserIds: this.options.allowedUserIds, + botToken: telegram.botToken, + allowedUserIds: telegram.allowedUserIds, maxAgeSeconds: MAX_AUTH_AGE_SECONDS, }); } + private refreshCodexHealth(): void { + if (this.#healthRefresh !== undefined) return; + const request = this.options.codex.account(true); + this.#healthRefresh = (async () => { + try { + this.#codexHealth = classifyCodexHealth(await Promise.race([request, healthTimeout()])); + } catch { + this.#codexHealth = "degraded"; + } + // Keep the single-flight guard if the RPC itself outlives our health + // timeout; otherwise a wedged app-server would accumulate requests. + await request.catch(() => undefined); + })().finally(() => { + this.#healthRefresh = undefined; + }); + } + private methodNotAllowed(response: ServerResponse, allow: string): void { response.setHeader("Allow", allow); this.sendError(response, 405, "Method not allowed"); @@ -488,6 +521,25 @@ export class MiniAppServer { } } +export type CodexHealth = + | "starting" + | "authenticated" + | "not_required" + | "needs_login" + | "degraded"; + +export function classifyCodexHealth(status: GetAccountResponse): CodexHealth { + if (status.account !== null) return "authenticated"; + return status.requiresOpenaiAuth ? "needs_login" : "not_required"; +} + +function healthTimeout(): Promise { + return new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error("Codex health timed out")), 5_000); + timer.unref(); + }); +} + function telegramScheduleScope(userId: number): Readonly<{ owner: ProviderReference; conversation: ProviderReference; diff --git a/test/health.test.ts b/test/health.test.ts new file mode 100644 index 0000000..bd3aa5b --- /dev/null +++ b/test/health.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; +import { classifyCodexHealth } from "../src/miniapp/server.js"; + +describe("Codex health", () => { + test("distinguishes login from configurations that need no account", () => { + expect(classifyCodexHealth({ account: null, requiresOpenaiAuth: true })).toBe("needs_login"); + expect(classifyCodexHealth({ account: null, requiresOpenaiAuth: false })).toBe("not_required"); + expect( + classifyCodexHealth({ + account: { type: "apiKey" }, + requiresOpenaiAuth: true, + }), + ).toBe("authenticated"); + }); +});