(() => { "use strict"; const RUNTIME_DEFAULTS = Object.freeze({ jmap_timeout_ms: 15000, telegram_timeout_ms: 10000, llm_timeout_ms: 30000, max_retries: 3, }); const RUNTIME_LIMITS = Object.freeze({ jmap_timeout_ms: { min: 100, max: 300000 }, telegram_timeout_ms: { min: 100, max: 300000 }, llm_timeout_ms: { min: 100, max: 300000 }, max_retries: { min: 0, max: 5 }, }); const RUNTIME_FIELDS = Object.freeze([ ["jmap_timeout_ms", "jmap-timeout"], ["telegram_timeout_ms", "telegram-timeout"], ["llm_timeout_ms", "llm-timeout"], ["max_retries", "max-retries"], ]); const I64_MIN = -(2n ** 63n); const I64_MAX = (2n ** 63n) - 1n; const bootstrapInput = document.querySelector("#bootstrap-input"); const authForm = document.querySelector("#auth-form"); const authCard = document.querySelector("#auth-card"); const configPanel = document.querySelector("#config-panel"); const businessForm = document.querySelector("#business-form"); const businessFields = document.querySelector("#business-fields"); const runtimeForm = document.querySelector("#runtime-form"); const runtimeFields = document.querySelector("#runtime-fields"); const connectButton = document.querySelector("#connect-button"); const reloadButton = document.querySelector("#reload-button"); const logoutButton = document.querySelector("#logout-button"); const defaultsButton = document.querySelector("#defaults-button"); const businessSaveButton = document.querySelector("#business-save-button"); const runtimeSaveButton = document.querySelector("#runtime-save-button"); const businessSaveState = document.querySelector("#business-save-state"); const runtimeSaveState = document.querySelector("#runtime-save-state"); const sessionExpiry = document.querySelector("#session-expiry"); const llmEnabled = document.querySelector("#llm-enabled"); const llmApiKey = document.querySelector("#llm-api-key"); const llmBaseUrl = document.querySelector("#llm-base-url"); const llmModel = document.querySelector("#llm-model"); const notice = document.querySelector("#notice"); // C-NO-LOCAL-WRITE / C-REDIS-ONLY-STATE: credentials and configuration stay in page memory. let adminSession = ""; let expiryTimer = 0; let busyAction = ""; let runtimeLoaded = false; class ApiError extends Error { constructor(status, message) { super(message); this.status = status; } } function showNotice(kind, message) { notice.className = `notice notice-${kind}`; notice.textContent = message; notice.setAttribute("role", kind === "error" ? "alert" : "status"); } function updateButtons() { const busy = Boolean(busyAction); connectButton.disabled = busy; reloadButton.disabled = busy || !adminSession; logoutButton.disabled = busy || !adminSession; businessSaveButton.disabled = busy || !adminSession; runtimeSaveButton.disabled = busy || !runtimeLoaded || !adminSession; defaultsButton.disabled = busy || !runtimeLoaded || !adminSession; businessSaveButton.classList.toggle("is-busy", busy && busyAction === "business-save"); runtimeSaveButton.classList.toggle("is-busy", busy && busyAction === "runtime-save"); connectButton.textContent = busy && busyAction === "login" ? "正在授权…" : "创建会话"; reloadButton.textContent = busy && busyAction === "runtime-load" ? "正在读取…" : "重新读取运行参数"; businessSaveButton.querySelector(".button-label").textContent = busy && busyAction === "business-save" ? "正在保存并热加载…" : "保存业务配置并热加载"; runtimeSaveButton.querySelector(".button-label").textContent = busy && busyAction === "runtime-save" ? "正在保存…" : "保存运行参数"; } function setBusy(action = "") { busyAction = action; updateButtons(); } function parseResponseText(text, status) { if (status === 204 || text.trim() === "") return null; try { return JSON.parse(text); } catch { throw new Error("配置服务返回了无法识别的数据格式。"); } } async function request(path, method = "GET", body = undefined, bearer = adminSession) { const headers = new Headers({ Accept: "application/json" }); if (bearer) headers.set("Authorization", `Bearer ${bearer}`); const options = { method, headers, cache: "no-store", credentials: "omit", redirect: "error", referrerPolicy: "no-referrer", }; if (body !== undefined) { headers.set("Content-Type", "application/json"); options.body = typeof body === "string" ? body : JSON.stringify(body); } let response; try { response = await fetch(path, options); } catch { throw new Error("无法连接配置服务。请确认服务可用后重试。"); } const responseText = await response.text().catch(() => ""); if (!response.ok) throw new ApiError(response.status, responseText); return parseResponseText(responseText, response.status); } function showConfigView() { authCard.hidden = true; configPanel.hidden = false; } function showAuthView() { configPanel.hidden = true; authCard.hidden = false; bootstrapInput.value = ""; bootstrapInput.focus(); } function clearSecretFields() { for (const input of document.querySelectorAll('input[type="password"]')) input.value = ""; } function clearForms() { businessForm.reset(); runtimeForm.reset(); runtimeLoaded = false; runtimeFields.disabled = true; businessFields.disabled = true; runtimeSaveState.textContent = "读取成功后可编辑运行参数。"; businessSaveState.textContent = "完成管理会话验证后可填写完整配置。"; updateLLMRequirements(); } function clearSession() { adminSession = ""; if (expiryTimer) window.clearTimeout(expiryTimer); expiryTimer = 0; sessionExpiry.textContent = "会话仅保存在当前页面内存"; clearForms(); clearSecretFields(); setBusy(""); showAuthView(); } function expireSession(message) { clearSession(); showNotice("error", message); } function scheduleSessionExpiry(seconds) { if (expiryTimer) window.clearTimeout(expiryTimer); const duration = Math.max(1, Math.min(Number(seconds) || 900, 900)); sessionExpiry.textContent = `会话约 ${Math.ceil(duration / 60)} 分钟后过期;只保存在页面内存`; expiryTimer = window.setTimeout(() => { expireSession("管理会话已过期,已清除页面中的会话和表单内容。请重新授权。"); }, duration * 1000); } function normalizeRuntimeConfig(value) { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new Error("配置服务返回了无法识别的运行参数。"); } const config = {}; for (const [key] of RUNTIME_FIELDS) { const item = value[key]; const limit = RUNTIME_LIMITS[key]; if (!Number.isInteger(item) || item < limit.min || item > limit.max) { throw new Error("配置服务返回了超出范围的运行参数,请检查后端配置。"); } config[key] = item; } return config; } function fillRuntimeFields(config) { for (const [key, id] of RUNTIME_FIELDS) document.getElementById(id).value = String(config[key]); } function showRuntimePreview() { fillRuntimeFields(RUNTIME_DEFAULTS); runtimeLoaded = false; runtimeFields.disabled = true; runtimeSaveState.textContent = "默认值预览;读取后才能保存。"; updateButtons(); } function activateRuntimeConfig(config) { fillRuntimeFields(config); runtimeLoaded = true; runtimeFields.disabled = false; runtimeSaveState.textContent = "已读取 Redis 中的运行参数。"; runtimeSaveState.classList.remove("is-dirty"); updateButtons(); } function runtimeErrorMessage(error, phase) { if (error.status === 401) return "管理会话已失效,请重新授权。"; if (error.status === 400) return "运行参数请求格式无效(HTTP 400)。"; if (error.status === 422) return "运行参数超出允许范围(超时 100–300,000 毫秒,重试 0–5 次)。"; if (error.status === 503) return phase === "save" ? "Redis 不可用或初始化尚未完成;保存状态无法确认。已禁用运行参数保存,请恢复连接后重新读取。" : "Redis 不可用或初始化尚未完成。当前只显示默认值预览,运行参数保存已禁用;请稍后重试。"; if (error.status !== undefined) return `运行参数请求失败(HTTP ${error.status})。请稍后重试。`; return error.message || "运行参数请求失败,请检查服务状态后重试。"; } async function loadRuntimeConfig() { if (!adminSession || busyAction) return; showRuntimePreview(); setBusy("runtime-load"); showNotice("info", "正在从 Redis 读取运行参数…"); try { const config = normalizeRuntimeConfig(await request("/api/config")); activateRuntimeConfig(config); showNotice("success", "运行参数已读取。业务配置仍需手动完整填写。"); } catch (error) { if (error.status === 401) { expireSession("管理会话已失效,页面内容已清除。请重新创建会话。"); return; } runtimeLoaded = false; runtimeFields.disabled = true; runtimeSaveState.textContent = "读取未成功,保存已禁用。"; updateButtons(); showNotice("error", runtimeErrorMessage(error, "load")); } finally { if (adminSession) setBusy(""); } } function setFieldError(input, message) { input.setCustomValidity(message); input.reportValidity(); input.focus(); return null; } function clearCustomValidity(form) { for (const input of form.querySelectorAll("input, textarea")) input.setCustomValidity(""); } function isBlank(value) { return value.trim() === ""; } function parseI64(input, label) { const value = input.value.trim(); if (!/^-?\d+$/.test(value)) { setFieldError(input, `${label} 必须是十进制整数。`); throw new Error("invalid-field"); } const parsed = BigInt(value); if (parsed < I64_MIN || parsed > I64_MAX) { setFieldError(input, `${label} 超出有符号 64 位整数范围。`); throw new Error("invalid-field"); } return parsed; } function parseAllowlist(input) { const entries = input.value.split(/[\s,]+/).filter(Boolean); if (entries.length === 0) { setFieldError(input, "至少填写一个 chat ID。空白和逗号均可用作分隔符。"); throw new Error("invalid-field"); } const ids = []; const seen = new Set(); for (const entry of entries) { if (!/^-?\d+$/.test(entry)) { setFieldError(input, `白名单中的“${entry}”不是整数 chat ID。`); throw new Error("invalid-field"); } const id = BigInt(entry); if (id < I64_MIN || id > I64_MAX) { setFieldError(input, `白名单中的“${entry}”超出有符号 64 位整数范围。`); throw new Error("invalid-field"); } if (seen.has(id.toString())) { setFieldError(input, `白名单中有重复的 chat ID:${entry}。`); throw new Error("invalid-field"); } seen.add(id.toString()); ids.push(id); } return ids; } function validateHttpsUrl(input, label, { noCredentials = false, noQuery = false } = {}) { let parsed; try { parsed = new URL(input.value.trim()); } catch { setFieldError(input, `${label} 必须是有效的 HTTPS URL。`); throw new Error("invalid-field"); } if (parsed.protocol !== "https:" || !parsed.hostname) { setFieldError(input, `${label} 必须使用 HTTPS。`); throw new Error("invalid-field"); } if (noCredentials && (parsed.username || parsed.password)) { setFieldError(input, `${label} 不能包含用户名或密码。`); throw new Error("invalid-field"); } const rawValue = input.value.trim(); if (noQuery && (rawValue.includes("?") || rawValue.includes("#"))) { setFieldError(input, `${label} 不能包含查询参数或 #片段。`); throw new Error("invalid-field"); } return input.value.trim(); } function requireText(id, label, secret = false) { const input = document.getElementById(id); const value = secret ? input.value : input.value.trim(); if (isBlank(value)) { setFieldError(input, `${label} 不能为空。`); throw new Error("invalid-field"); } return value; } function readBusinessConfig() { clearCustomValidity(businessForm); const enabled = llmEnabled.checked; llmApiKey.required = enabled; llmBaseUrl.required = enabled; llmModel.required = enabled; try { const llmBaseValue = llmBaseUrl.value.trim(); const llmModelValue = llmModel.value.trim(); const llmKeyValue = llmApiKey.value; if (enabled && !llmBaseValue) return setFieldError(llmBaseUrl, "启用 LLM 时必须填写 Base URL。"); if (enabled && !llmModelValue) return setFieldError(llmModel, "启用 LLM 时必须填写模型名称。 "); if (enabled && isBlank(llmKeyValue)) return setFieldError(llmApiKey, "启用 LLM 时必须填写 API key。"); const llmBase = llmBaseValue ? (enabled ? validateHttpsUrl(llmBaseUrl, "LLM Base URL") : llmBaseValue) : null; const jmapUrlInput = document.getElementById("jmap-session-url"); const business = { bot_token: requireText("telegram-bot-token", "Telegram Bot Token", true), telegram_chat_id: parseI64(document.getElementById("telegram-chat-id"), "目标 Chat ID"), chat_allowlist: parseAllowlist(document.getElementById("chat-allowlist")), telegram_webhook_secret: requireText("telegram-webhook-secret", "Telegram Webhook Secret", true), jmap_session_url: validateHttpsUrl(jmapUrlInput, "JMAP Session URL", { noCredentials: true, noQuery: true }), jmap_username: requireText("jmap-username", "邮箱用户名"), jmap_password: requireText("jmap-password", "JMAP 应用专用密码", true), jmap_push_verification: requireText("jmap-push-verification", "JMAP Push 验证码", true), account_id: document.getElementById("account-id").value.trim() || null, llm_enabled: enabled, llm_allow_net: document.getElementById("llm-allow-net").checked, llm_api_key: isBlank(llmKeyValue) ? null : llmKeyValue, llm_base_url: llmBase, llm_model: llmModelValue || null, reconcile_token: requireText("reconcile-token", "Reconcile Token", true), worker_token: requireText("worker-token", "Worker Token", true), }; return business; } catch (error) { if (error.message === "invalid-field") return null; throw error; } } function stringifyWithBigInt(value) { if (typeof value === "bigint") return value.toString(); if (Array.isArray(value)) return `[${value.map(stringifyWithBigInt).join(",")}]`; if (value !== null && typeof value === "object") { return `{${Object.entries(value).map(([key, item]) => `${JSON.stringify(key)}:${stringifyWithBigInt(item)}`).join(",")}}`; } return JSON.stringify(value); } function businessErrorMessage(error) { if (error.status === 401) return "管理会话已失效。页面已清除会话和表单内容,请重新授权。"; if (error.status === 400) return "后端无法解析提交的 JSON(HTTP 400);请检查字段后重试。"; if (error.status === 422) return "业务配置校验失败(HTTP 422)。请检查必填项、HTTPS URL、LLM 设置和 chat ID 白名单。服务端没有返回密钥或错误详情。"; if (error.status === 503) return "业务配置未获成功确认(HTTP 503)。Redis 可能尚未就绪,或后端无法建立 JMAP/LLM 客户端;页面保留本次输入供检查和重试。"; if (error.status !== undefined) return `业务配置请求失败(HTTP ${error.status})。请稍后重试。`; return `${error.message || "业务配置请求失败。"} 请求结果可能无法确认;检查后可重新提交完整配置。`; } function runtimePayloadFromForm() { clearCustomValidity(runtimeForm); const config = {}; for (const [key, id] of RUNTIME_FIELDS) { const input = document.getElementById(id); const value = input.value.trim() === "" ? Number.NaN : Number(input.value); const limit = RUNTIME_LIMITS[key]; if (!Number.isInteger(value)) return setFieldError(input, "请输入整数。"), null; if (value < limit.min || value > limit.max) { return setFieldError(input, `请输入 ${limit.min} 到 ${limit.max} 之间的整数。`), null; } config[key] = value; } return config; } function updateLLMRequirements() { const enabled = llmEnabled.checked; llmApiKey.required = enabled; llmBaseUrl.required = enabled; llmModel.required = enabled; document.querySelector("#llm-key-required").textContent = enabled ? "必填" : "可选"; document.querySelector("#llm-url-required").textContent = enabled ? "必填" : "可选"; document.querySelector("#llm-model-required").textContent = enabled ? "必填" : "可选"; } authForm.addEventListener("submit", async (event) => { event.preventDefault(); if (busyAction) return; let bootstrapCredential = bootstrapInput.value; bootstrapInput.value = ""; if (isBlank(bootstrapCredential)) { showNotice("error", "请输入 Redis ACL 管理凭据。输入不会写入浏览器存储。"); bootstrapInput.focus(); return; } setBusy("login"); showNotice("info", "正在创建短期管理会话…"); try { const sessionInfo = await request("/api/admin/session", "POST", undefined, bootstrapCredential); bootstrapCredential = ""; if (!sessionInfo || typeof sessionInfo.session !== "string" || !sessionInfo.session) { throw new Error("管理接口返回了无效的会话响应。"); } adminSession = sessionInfo.session; scheduleSessionExpiry(sessionInfo.expires_in); showConfigView(); businessFields.disabled = false; businessSaveState.textContent = "完整填写 BusinessConfigWire 后可提交。密钥不会回显。"; showRuntimePreview(); setBusy(""); showNotice("success", "管理会话已创建。运行参数正在读取;业务配置需要完整重新填写。 "); await loadRuntimeConfig(); } catch (error) { bootstrapCredential = ""; setBusy(""); if (error.status === 401) showNotice("error", "Redis 管理凭据无效,或服务尚未启用管理会话。请检查 Redis URL ACL 密码。 "); else if (error.status === 503) showNotice("error", "Redis 不可用或初始化尚未完成,暂时无法创建管理会话。请稍后重试。 "); else if (error.status !== undefined) showNotice("error", `无法创建管理会话(HTTP ${error.status})。`); else showNotice("error", error.message || "无法连接管理服务,请检查网络后重试。"); bootstrapInput.focus(); } }); reloadButton.addEventListener("click", loadRuntimeConfig); logoutButton.addEventListener("click", async () => { if (!adminSession || busyAction) return; setBusy("logout"); const session = adminSession; try { await request("/api/admin/session/revoke", "POST", undefined, session); } catch { // Local credentials are cleared even when the revocation request cannot reach Redis. } clearSession(); showNotice("info", "已退出;管理会话和表单中的密钥已从页面内存清除。"); }); defaultsButton.addEventListener("click", () => { if (!runtimeLoaded || busyAction) return; fillRuntimeFields(RUNTIME_DEFAULTS); runtimeSaveState.textContent = "已恢复默认预览值;保存后才会写入 Redis。"; runtimeSaveState.classList.add("is-dirty"); showNotice("info", "运行参数已恢复为默认值预览。点击“保存运行参数”后才会更新 Redis。"); }); businessForm.addEventListener("input", (event) => { event.target.setCustomValidity(""); if (!adminSession) return; businessSaveState.textContent = "表单包含未提交内容;每次提交会完整替换现有业务配置。"; businessSaveState.classList.add("is-dirty"); }); runtimeForm.addEventListener("input", (event) => { event.target.setCustomValidity(""); if (!runtimeLoaded) return; runtimeSaveState.textContent = "有尚未保存的运行参数修改。"; runtimeSaveState.classList.add("is-dirty"); }); llmEnabled.addEventListener("change", updateLLMRequirements); businessForm.addEventListener("submit", async (event) => { event.preventDefault(); if (!adminSession || busyAction) return; const config = readBusinessConfig(); if (!config) return; setBusy("business-save"); showNotice("info", "正在提交完整业务配置;后端会先构建客户端,再保存并切换运行实例…"); try { await request("/api/business-config", "PUT", stringifyWithBigInt(config)); clearSecretFields(); businessSaveState.textContent = "业务配置已保存,新配置已热加载。"; businessSaveState.classList.remove("is-dirty"); showNotice("success", "业务配置已保存并热加载。密钥输入已清空;下次完整替换时需要重新填写。"); updateButtons(); } catch (error) { if (error.status === 401) { expireSession("管理会话已失效,已清除页面中的会话与配置。请重新授权。"); return; } businessSaveState.textContent = "未收到保存成功确认;请检查服务状态。"; showNotice("error", businessErrorMessage(error)); } finally { if (adminSession) setBusy(""); } }); runtimeForm.addEventListener("submit", async (event) => { event.preventDefault(); if (!adminSession || !runtimeLoaded || busyAction) return; const config = runtimePayloadFromForm(); if (!config) return; setBusy("runtime-save"); showNotice("info", "正在保存运行参数到 Redis…"); try { const saved = normalizeRuntimeConfig(await request("/api/config", "PUT", config)); activateRuntimeConfig(saved); showNotice("success", "运行参数已保存到 Redis,并立即应用于后续请求。"); } catch (error) { if (error.status === 401) { expireSession("管理会话已失效,已清除页面中的会话与配置。请重新授权。"); return; } if (error.status >= 500 || error.status === undefined) { runtimeLoaded = false; runtimeFields.disabled = true; runtimeSaveState.textContent = "保存状态无法确认;重新读取成功前已禁用保存。"; updateButtons(); } showNotice("error", runtimeErrorMessage(error, "save")); } finally { if (adminSession) setBusy(""); } }); window.addEventListener("pagehide", () => { adminSession = ""; if (expiryTimer) window.clearTimeout(expiryTimer); clearForms(); clearSecretFields(); configPanel.hidden = true; authCard.hidden = false; bootstrapInput.value = ""; setBusy(""); }); showRuntimePreview(); updateLLMRequirements(); updateButtons(); })();