/** * Regression tests for Telegram config and setup prompt defaults * Covers persisted config state plus token-prefill priority across stored config, environment variables, and placeholder fallback */ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdtemp, readdir, readFile, rm, stat, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import type { TelegramConfig } from "../lib/config.ts"; import { createTelegramConfigControls, createTelegramConfigStore, createTelegramProactivePushTargetGetter, createTelegramTimeInjectionModeGetter, createTelegramTimeInjectionModeSetter, createTelegramUserPairingRuntime, createTelegramVoiceReplyModeConfiguredChecker, createTelegramVoiceReplyModeGetter, createTelegramVoiceReplyModeSetter, getTelegramAuthorizationState, isValidTelegramProfileName, pairTelegramUserIfNeeded, readTelegramConfig, setGlobalTelegramConfigRuntime, updateTelegramVoiceConfig, writeTelegramConfig, } from "../lib/config.ts"; import { createTelegramSettingsMenuRuntime } from "../lib/menu-settings.ts"; import { createTelegramSetupPromptRuntime, getTelegramBotTokenInputDefault, getTelegramBotTokenPromptSpec, runTelegramSetup, } from "../lib/setup.ts"; test("Telegram profile names allow only lowercase letters and digits", () => { assert.equal(isValidTelegramProfileName("work2"), true); assert.equal(isValidTelegramProfileName("previous"), true); assert.equal(isValidTelegramProfileName("prev"), true); for (const name of [ "default", "main", "active", "Work", "work-one", "work_one", "work.one", "work one", "", ]) { assert.equal(isValidTelegramProfileName(name), false, name); } }); test("Telegram config helper returns empty config when file is absent", async () => { const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-missing-config-")); assert.deepEqual( await readTelegramConfig(join(agentDir, "telegram.json")), {}, ); }); test("Telegram proactive target getter prefers active then assigned targets", () => { const target = createTelegramProactivePushTargetGetter({ getActiveTurnTarget: () => undefined, getAssignedTarget: () => ({ chatId: -1007, threadId: 42 }), getAllowedUserId: () => 7, }); assert.deepEqual(target(), { chatId: -1007, threadId: 42 }); const activeTarget = createTelegramProactivePushTargetGetter({ getActiveTurnTarget: () => ({ chatId: -1008, threadId: 99 }), getAssignedTarget: () => ({ chatId: -1007, threadId: 42 }), getAllowedUserId: () => 7, }); assert.deepEqual(activeTarget(), { chatId: -1008, threadId: 99 }); const privateTarget = createTelegramProactivePushTargetGetter({ getActiveTurnTarget: () => undefined, getAssignedTarget: () => undefined, getAllowedUserId: () => 7, }); assert.deepEqual(privateTarget(), { chatId: 7 }); }); test("Telegram config helpers persist and reload config", async () => { const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-config-")); const configPath = join(agentDir, "telegram.json"); const config = { botToken: "123:abc", botUsername: "demo_bot", allowedUserId: 42, }; await writeTelegramConfig(agentDir, configPath, config); const reloaded = await readTelegramConfig(configPath); assert.deepEqual(reloaded, config); const raw = await readFile(configPath, "utf8"); assert.match(raw, /demo_bot/); assert.equal((await stat(configPath)).mode & 0o777, 0o600); assert.deepEqual( (await readdir(agentDir)).filter((entry) => entry.includes(".tmp-")), [], ); }); test("Telegram config store persists active named profile session fields without overwriting default", async () => { const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-profile-config-")); const configPath = join(agentDir, "telegram.json"); const store = createTelegramConfigStore({ agentDir, configPath, initialConfig: { botToken: "default-token", botUsername: "default_bot", allowedUserId: 1, voice: { replyMode: "mirror" }, profiles: { omp: { botToken: "omp-token", botUsername: "omp_bot", allowedUserId: 2, lastUpdateId: 10, }, }, }, }); assert.equal(store.activateProfile("omp"), true); assert.equal(store.getBotToken(), "omp-token"); assert.equal(store.getAllowedUserId(), 2); store.setAllowedUserId(3); await store.persist({ ...store.get(), lastUpdateId: 99 }); assert.deepEqual(await readTelegramConfig(configPath), { botToken: "default-token", botUsername: "default_bot", allowedUserId: 1, voice: { replyMode: "mirror" }, profiles: { omp: { botToken: "omp-token", botUsername: "omp_bot", allowedUserId: 3, lastUpdateId: 99, }, }, }); }); test("Telegram config transactions merge concurrent profile offsets and global fields", async () => { const dir = await mkdtemp(join(tmpdir(), "pi-telegram-config-race-")); const configPath = join(dir, "telegram.json"); const startPath = join(dir, "start"); await writeFile( configPath, JSON.stringify({ botToken: "default-token", profiles: { work: { botToken: "work-token", lastUpdateId: 1 }, personal: { botToken: "personal-token", lastUpdateId: 2 }, }, }), ); const moduleUrl = new URL("../lib/config.ts", import.meta.url).href; const children = [ { profile: "work", offset: "11", field: "proactive" }, { profile: "personal", offset: "22", field: "voice" }, ].map(({ profile, offset, field }) => { const readyPath = join(dir, `ready-${profile}`); const source = ` import { existsSync, writeFileSync } from "node:fs"; import { createTelegramConfigStore } from ${JSON.stringify(moduleUrl)}; const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); const store = createTelegramConfigStore({ agentDir: process.env.AGENT_DIR, configPath: process.env.CONFIG_PATH, }); await store.load(); if (!store.activateProfile(process.env.PROFILE)) throw new Error("profile missing"); writeFileSync(process.env.READY_PATH, "ready"); while (!existsSync(process.env.START_PATH)) sleep(2); store.update((config) => { config.lastUpdateId = Number(process.env.OFFSET); if (process.env.FIELD === "proactive") config.proactivePush = true; else config.voice = { ...(config.voice ?? {}), replyMode: "mirror" }; }); await store.persist(); `; const child = spawn( process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", source], { env: { ...process.env, AGENT_DIR: dir, CONFIG_PATH: configPath, PROFILE: profile, OFFSET: offset, FIELD: field, READY_PATH: readyPath, START_PATH: startPath, }, stdio: ["ignore", "ignore", "pipe"], }, ); const done = new Promise((resolve, reject) => { let stderr = ""; child.stderr.on("data", (chunk) => (stderr += String(chunk))); child.on("error", reject); child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`config child exited ${code}: ${stderr}`)), ); }); return { readyPath, done }; }); try { const deadline = Date.now() + 3000; while ( !children.every((child) => existsSync(child.readyPath)) && Date.now() < deadline ) { await new Promise((resolve) => setTimeout(resolve, 5)); } assert.equal(children.every((child) => existsSync(child.readyPath)), true); await writeFile(startPath, "start"); await Promise.all(children.map((child) => child.done)); assert.deepEqual(await readTelegramConfig(configPath), { botToken: "default-token", proactivePush: true, voice: { replyMode: "mirror" }, profiles: { work: { botToken: "work-token", lastUpdateId: 11 }, personal: { botToken: "personal-token", lastUpdateId: 22 }, }, }); } finally { await rm(dir, { recursive: true, force: true }); } }); test("Telegram config keeps same-profile polling offsets monotonic", async () => { const dir = await mkdtemp(join(tmpdir(), "pi-telegram-config-offset-")); const configPath = join(dir, "telegram.json"); await writeFile( configPath, JSON.stringify({ profiles: { work: { botToken: "work-token", lastUpdateId: 10 } }, }), ); try { const stale = createTelegramConfigStore({ agentDir: dir, configPath }); const replacement = createTelegramConfigStore({ agentDir: dir, configPath }); await stale.load(); await replacement.load(); assert.equal(stale.activateProfile("work"), true); assert.equal(replacement.activateProfile("work"), true); const stalePollingConfig = stale.get(); stalePollingConfig.lastUpdateId = 12; const replacementPollingConfig = replacement.get(); replacementPollingConfig.lastUpdateId = 20; await replacement.persist(replacementPollingConfig); await stale.persist(stalePollingConfig); assert.equal( (await readTelegramConfig(configPath)).profiles?.work.lastUpdateId, 20, ); } finally { await rm(dir, { recursive: true, force: true }); } }); test("Telegram config store rejects missing named profile activation", () => { const store = createTelegramConfigStore({ initialConfig: { profiles: { work: { botToken: "work-token" } } }, }); assert.equal(store.activateProfile("missing"), false); assert.equal(store.getActiveProfileName(), undefined); assert.equal(store.getBotToken(), undefined); }); test("Telegram config load recovers invalid JSON and records a diagnostic", async () => { const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-invalid-config-")); const configPath = join(agentDir, "telegram.json"); await writeFile(configPath, "{not valid json", "utf8"); const events: string[] = []; const store = createTelegramConfigStore({ agentDir, configPath, initialConfig: { botToken: "previous" }, recordRuntimeEvent: (category, error, details) => { events.push( `${category}:${error instanceof Error ? error.name : String(error)}:${details?.phase}:${String(details?.recoveryPath ?? "")}`, ); }, }); await store.load(); assert.deepEqual(store.get(), {}); const entries = await readdir(agentDir); const recovery = entries.find((entry) => entry.startsWith("telegram.json.invalid-"), ); assert.ok(recovery); assert.equal( await readFile(join(agentDir, recovery), "utf8"), "{not valid json", ); assert.equal(entries.includes("telegram.json"), false); assert.equal(events.length, 1); assert.match(events[0] ?? "", /^config:SyntaxError:load:/); }); test("Telegram voice reply mode helpers distinguish implicit and explicit manual", () => { let config: TelegramConfig = {}; const store = { get: () => config }; const getMode = createTelegramVoiceReplyModeGetter(store); const isConfigured = createTelegramVoiceReplyModeConfiguredChecker(store); assert.equal(getMode(), "manual"); assert.equal(isConfigured(), false); config = { voice: { replyMode: "manual" } }; assert.equal(getMode(), "manual"); assert.equal(isConfigured(), true); config = { voice: { replyMode: "invalid" } } as unknown as TelegramConfig; assert.equal(getMode(), "manual"); assert.equal(isConfigured(), false); }); test("Telegram voice reply mode setter persists telegram.json", async () => { const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-voice-mode-")); const configPath = join(agentDir, "telegram.json"); const store = createTelegramConfigStore({ initialConfig: { botToken: "123:abc" }, agentDir, configPath, }); const setMode = createTelegramVoiceReplyModeSetter(store); await setMode("mirror"); assert.deepEqual(store.get().voice, { replyMode: "mirror" }); assert.deepEqual(await readTelegramConfig(configPath), { botToken: "123:abc", voice: { replyMode: "mirror" }, }); await setMode(undefined); assert.equal(store.get().voice, undefined); assert.deepEqual(await readTelegramConfig(configPath), { botToken: "123:abc", }); }); test("Telegram settings setters reload before scoped writes to preserve shared config changes", async () => { const agentDir = await mkdtemp( join(tmpdir(), "pi-telegram-shared-settings-"), ); const configPath = join(agentDir, "telegram.json"); await writeTelegramConfig(agentDir, configPath, { botToken: "123:abc" }); const firstStore = createTelegramConfigStore({ agentDir, configPath }); const secondStore = createTelegramConfigStore({ agentDir, configPath }); await firstStore.load(); await secondStore.load(); const setVoiceMode = createTelegramVoiceReplyModeSetter(firstStore); const controls = createTelegramConfigControls(secondStore); await setVoiceMode("mirror"); await controls.setProactivePushEnabled(true); await controls.setDraftPreviewsEnabled(true); await controls.setAssistantRenderingMode("html"); assert.deepEqual(await readTelegramConfig(configPath), { botToken: "123:abc", proactivePush: true, assistant: { draftPreviews: true, rendering: "html" }, voice: { replyMode: "mirror" }, }); assert.equal(controls.getAssistantRenderingMode(), "html"); assert.deepEqual(secondStore.get().voice, { replyMode: "mirror" }); }); test("Telegram draft preview config reads and migrates legacy rich flag", async () => { const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-draft-legacy-")); const configPath = join(agentDir, "telegram.json"); await writeTelegramConfig(agentDir, configPath, { botToken: "123:abc", richDraftPreviews: true, }); const store = createTelegramConfigStore({ agentDir, configPath }); await store.load(); const controls = createTelegramConfigControls(store); assert.equal(controls.areDraftPreviewsEnabled(), true); await controls.setDraftPreviewsEnabled(false); assert.deepEqual(await readTelegramConfig(configPath), { botToken: "123:abc", assistant: { draftPreviews: false }, }); }); test("Telegram settings menu callbacks persist voice and time settings to telegram.json", async () => { const agentDir = await mkdtemp( join(tmpdir(), "pi-telegram-settings-callbacks-"), ); const configPath = join(agentDir, "telegram.json"); const store = createTelegramConfigStore({ initialConfig: { botToken: "123:abc" }, agentDir, configPath, }); const controls = createTelegramConfigControls(store); const state = { chatId: 1, messageId: 2, mode: "settings" as const, page: 0, scope: "all" as const, scopedModels: [], allModels: [], }; const runtime = createTelegramSettingsMenuRuntime({ ...controls, getModelMenuState: async () => state, getStoredModelMenuState: () => state, storeModelMenuState: () => {}, editInteractiveMessage: async () => {}, sendInteractiveMessage: async () => state.messageId, answerCallbackQuery: async () => {}, }); assert.equal( await runtime.handleCallbackQuery( { id: "voice", data: "settings:set:voice-reply:mirror", message: { message_id: state.messageId }, }, {}, ), true, ); assert.equal( await runtime.handleCallbackQuery( { id: "time", data: "settings:set:time-injection:always", message: { message_id: state.messageId }, }, {}, ), true, ); assert.equal( await runtime.handleCallbackQuery( { id: "drafts", data: "settings:set:draft-previews:on", message: { message_id: state.messageId }, }, {}, ), true, ); assert.deepEqual(await readTelegramConfig(configPath), { botToken: "123:abc", assistant: { draftPreviews: true }, voice: { replyMode: "mirror" }, time: { injectionMode: "always" }, }); }); test("Telegram time injection mode setter persists telegram.json", async () => { const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-time-mode-")); const configPath = join(agentDir, "telegram.json"); const store = createTelegramConfigStore({ initialConfig: { botToken: "123:abc", time: { interval: 5000 } }, agentDir, configPath, }); const getMode = createTelegramTimeInjectionModeGetter(store); const setMode = createTelegramTimeInjectionModeSetter(store); assert.equal(getMode(), "hidden"); await setMode("interval"); assert.equal(getMode(), "interval"); assert.deepEqual(await readTelegramConfig(configPath), { botToken: "123:abc", time: { interval: 5000, injectionMode: "interval" }, }); await setMode("hidden"); assert.equal(getMode(), "hidden"); assert.deepEqual(await readTelegramConfig(configPath), { botToken: "123:abc", time: { interval: 5000 }, }); }); test("Telegram config runtime lets extensions update live voice config", async () => { let voice: TelegramConfig["voice"] | undefined; setGlobalTelegramConfigRuntime({ updateVoiceConfig: (nextVoice) => { voice = nextVoice; }, }); try { assert.equal(updateTelegramVoiceConfig({ replyMode: "mirror" }), true); assert.deepEqual(voice, { replyMode: "mirror" }); } finally { setGlobalTelegramConfigRuntime(undefined); } assert.equal(updateTelegramVoiceConfig({ replyMode: "always" }), false); }); test("Telegram config store owns load, mutation, and persistence", async () => { const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-store-")); const configPath = join(agentDir, "telegram.json"); const store = createTelegramConfigStore({ initialConfig: { botToken: "initial", inboundHandlers: [{ type: "text", template: "translate" }], attachmentHandlers: [{ mime: "audio/*", template: "transcribe {file}" }], }, agentDir, configPath, }); assert.deepEqual(store.get(), { botToken: "initial", inboundHandlers: [{ type: "text", template: "translate" }], attachmentHandlers: [{ mime: "audio/*", template: "transcribe {file}" }], }); store.update((config) => { config.allowedUserId = 42; }); assert.equal(store.getBotToken(), "initial"); assert.equal(store.hasBotToken(), true); assert.equal(store.getAllowedUserId(), 42); assert.deepEqual(store.getInboundHandlers(), [ { type: "text", template: "translate" }, { mime: "audio/*", template: "transcribe {file}" }, ]); assert.deepEqual(store.getAttachmentHandlers(), [ { mime: "audio/*", template: "transcribe {file}" }, ]); store.setAllowedUserId(43); assert.equal(store.getAllowedUserId(), 43); await store.persist(); assert.deepEqual(await readTelegramConfig(configPath), { botToken: "initial", inboundHandlers: [{ type: "text", template: "translate" }], attachmentHandlers: [{ mime: "audio/*", template: "transcribe {file}" }], allowedUserId: 43, }); store.set({ botToken: "next" }); assert.deepEqual(store.get(), { botToken: "next" }); await store.load(); assert.deepEqual(store.get(), { botToken: "initial", inboundHandlers: [{ type: "text", template: "translate" }], attachmentHandlers: [{ mime: "audio/*", template: "transcribe {file}" }], allowedUserId: 43, }); }); test("Telegram config helpers classify authorization state for pair, allow, and deny", () => { assert.deepEqual(getTelegramAuthorizationState(10), { kind: "pair", userId: 10, }); assert.deepEqual(getTelegramAuthorizationState(10, 10), { kind: "allow" }); assert.deepEqual(getTelegramAuthorizationState(10, 11), { kind: "deny" }); }); test("Telegram config helpers pair only when no user is configured", async () => { const events: string[] = []; let allowedUserId: number | undefined; assert.equal( await pairTelegramUserIfNeeded(10, { allowedUserId, ctx: "ctx", setAllowedUserId: (userId) => { allowedUserId = userId; events.push(`set:${userId}`); }, persistConfig: async () => { events.push("persist"); }, updateStatus: (ctx) => { events.push(`status:${ctx}`); }, }), true, ); assert.equal( await pairTelegramUserIfNeeded(11, { allowedUserId, ctx: "ctx", setAllowedUserId: () => { events.push("unexpected:set"); }, persistConfig: async () => { events.push("unexpected:persist"); }, updateStatus: () => { events.push("unexpected:status"); }, }), false, ); assert.equal(allowedUserId, 10); assert.deepEqual(events, ["set:10", "persist", "status:ctx"]); }); test("Telegram config pairing swallows only stale context status errors", async () => { await assert.doesNotReject(() => pairTelegramUserIfNeeded(10, { ctx: "ctx", setAllowedUserId: () => {}, persistConfig: async () => {}, updateStatus: () => { throw new Error("ctx is stale after session replacement"); }, }), ); await assert.rejects( () => pairTelegramUserIfNeeded(10, { ctx: "ctx", setAllowedUserId: () => {}, persistConfig: async () => {}, updateStatus: () => { throw new Error("status broke"); }, }), /status broke/, ); }); test("Telegram config pairing runtime binds config and status ports", async () => { const events: string[] = []; let allowedUserId: number | undefined; const runtime = createTelegramUserPairingRuntime({ getAllowedUserId: () => allowedUserId, setAllowedUserId: (userId) => { allowedUserId = userId; events.push(`set:${userId}`); }, persistConfig: async () => { events.push("persist"); }, updateStatus: (ctx: string) => { events.push(`status:${ctx}`); }, }); assert.equal(await runtime.pairIfNeeded(7, "ctx"), true); assert.equal(await runtime.pairIfNeeded(8, "ctx"), false); assert.deepEqual(events, ["set:7", "persist", "status:ctx"]); }); test("Telegram config pairing runtime reports the new owner only after persisting", async () => { const events: string[] = []; let allowedUserId: number | undefined; const runtime = createTelegramUserPairingRuntime({ getAllowedUserId: () => allowedUserId, setAllowedUserId: (userId) => { allowedUserId = userId; }, persistConfig: async () => { events.push("persist"); }, updateStatus: () => { events.push("status"); }, onPaired: (userId, ctx: string) => { events.push(`paired:${userId}:${ctx}`); }, }); assert.equal(await runtime.pairIfNeeded(7, "ctx"), true); assert.equal(await runtime.pairIfNeeded(8, "ctx"), false); assert.deepEqual(events, ["persist", "status", "paired:7:ctx"]); }); test("Bot token input prefers stored config over env vars", () => { const value = getTelegramBotTokenInputDefault( { TELEGRAM_KEY: "key-last", TELEGRAM_TOKEN: "token-third", TELEGRAM_BOT_KEY: "key-second", TELEGRAM_BOT_TOKEN: "token-first", }, "stored-token", ); assert.equal(value, "stored-token"); }); test("Bot token input prefers the first configured Telegram env var when no config exists", () => { const value = getTelegramBotTokenInputDefault({ TELEGRAM_KEY: "key-last", TELEGRAM_TOKEN: "token-third", TELEGRAM_BOT_KEY: "key-second", TELEGRAM_BOT_TOKEN: "token-first", }); assert.equal(value, "token-first"); }); test("Bot token prompt uses the editor when a real prefill exists", () => { const prompt = getTelegramBotTokenPromptSpec({ TELEGRAM_BOT_TOKEN: "token-first", }); assert.deepEqual(prompt, { method: "editor", value: "token-first", }); }); test("Bot token prompt shows stored config before env values", () => { const prompt = getTelegramBotTokenPromptSpec( { TELEGRAM_BOT_TOKEN: "token-first", }, "stored-token", ); assert.deepEqual(prompt, { method: "editor", value: "stored-token", }); }); test("Bot token input skips blank env vars and falls back to config", () => { const value = getTelegramBotTokenInputDefault( { TELEGRAM_BOT_TOKEN: " ", TELEGRAM_BOT_KEY: "", TELEGRAM_TOKEN: " ", }, "stored-token", ); assert.equal(value, "stored-token"); }); test("Bot token input falls back to placeholder when no value exists", () => { const value = getTelegramBotTokenInputDefault({}); assert.equal(value, "123456:ABCDEF..."); }); test("Bot token prompt uses placeholder input when no prefill exists", () => { const prompt = getTelegramBotTokenPromptSpec({}); assert.deepEqual(prompt, { method: "input", value: "123456:ABCDEF...", }); }); test("Setup runtime prompts, validates token, persists config, and starts polling", async () => { const events: string[] = []; const nextConfig = await runTelegramSetup({ hasUI: true, env: { TELEGRAM_BOT_TOKEN: "env-token" }, config: { allowedUserId: 7 }, promptInput: async () => { events.push("input"); return undefined; }, promptEditor: async (label, value) => { events.push(`editor:${label}:${value}`); return "new-token"; }, getMe: async (botToken) => { events.push(`getMe:${botToken}`); return { ok: true, result: { id: 42, username: "demo_bot" } }; }, persistConfig: async (config) => { events.push(`persist:${config.botToken}:${config.botUsername}`); }, notify: (message, level) => { events.push(`notify:${level}:${message}`); }, startPolling: async () => { events.push("poll"); }, updateStatus: () => { events.push("status"); }, }); assert.deepEqual(nextConfig, { status: "success", config: { allowedUserId: 7, botToken: "new-token", botId: 42, botUsername: "demo_bot", }, }); assert.deepEqual(events, [ "editor:Telegram bot token:env-token", "getMe:new-token", "persist:new-token:demo_bot", "notify:info:Telegram bot connected: @demo_bot", "notify:info:Send /start to your bot in Telegram to pair this extension with your account.", "poll", "status", ]); }); test("Setup runtime reports invalid tokens without persisting", async () => { const events: string[] = []; const nextConfig = await runTelegramSetup({ hasUI: true, env: {}, config: {}, promptInput: async () => "bad-token", promptEditor: async () => undefined, getMe: async () => ({ ok: false, description: "nope" }), persistConfig: async () => { events.push("persist"); }, notify: (message, level) => { events.push(`notify:${level}:${message}`); }, startPolling: async () => { events.push("poll"); }, updateStatus: () => { events.push("status"); }, }); assert.deepEqual(nextConfig, { status: "validation-failed" }); assert.deepEqual(events, ["notify:error:nope"]); }); test("Setup prompt runtime guards concurrent setup and stores successful config", async () => { const events: string[] = []; let config: TelegramConfig = { allowedUserId: 7 }; let inProgress = false; const promptForConfig = createTelegramSetupPromptRuntime({ env: { TELEGRAM_BOT_TOKEN: "env-token" }, getConfig: () => config, setConfig: (nextConfig) => { config = nextConfig; events.push(`set:${nextConfig.botUsername}`); }, setupGuard: { start: () => { events.push("start"); if (inProgress) return false; inProgress = true; return true; }, finish: () => { events.push("finish"); inProgress = false; }, }, getMe: async (botToken) => { events.push(`getMe:${botToken}`); return { ok: true, result: { id: 42, username: "demo_bot" } }; }, persistConfig: async (nextConfig) => { events.push(`persist:${nextConfig.botToken}`); }, startPolling: async () => { events.push("poll"); }, updateStatus: () => { events.push("status"); }, }); await promptForConfig({ hasUI: true, ui: { input: async () => undefined, editor: async (_label, value) => { events.push(`editor:${value}`); return "new-token"; }, notify: (message, level) => { events.push(`notify:${level}:${message}`); }, }, }); inProgress = true; await promptForConfig({ hasUI: true, ui: { input: async () => { events.push("blocked-input"); return undefined; }, editor: async () => { events.push("blocked-editor"); return undefined; }, notify: () => { events.push("blocked-notify"); }, }, }); assert.deepEqual(config, { allowedUserId: 7, botToken: "new-token", botId: 42, botUsername: "demo_bot", }); assert.deepEqual(events, [ "start", "editor:env-token", "getMe:new-token", "set:demo_bot", "persist:new-token", "notify:info:Telegram bot connected: @demo_bot", "notify:info:Send /start to your bot in Telegram to pair this extension with your account.", "poll", "status", "finish", "start", ]); });