Add WhatsApp session bind/unbind to Admin Runtime, and fix related Admin JS errors.

Expose sidecar status, start/stop, QR bind, and unbind so 405 session failures are visible instead of a stuck connecting state.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-17 22:23:35 +08:00
parent 1bdbfc3b85
commit 5c6833612f
16 changed files with 1123 additions and 72 deletions

View file

@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>oliver</title>
<!-- Avoid render-blocking external fonts (can stall first paint ~30s on blocked networks). -->
<link rel="stylesheet" href="/admin/assets/styles.css?v=20260812-22" />
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css?v=20260812-22" />
<link rel="stylesheet" href="/admin/assets/theme-presets.css?v=20260812-22" />
<link rel="stylesheet" href="/admin/assets/styles.css?v=20260817-wa6" />
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css?v=20260817-wa6" />
<link rel="stylesheet" href="/admin/assets/theme-presets.css?v=20260817-wa6" />
<style>
/* First paint before app.js runs: keep admin from showing a blank dark screen. */
.admin-boot-splash {
@ -129,33 +129,33 @@
<script type="importmap">
{
"imports": {
"/admin/assets/app.js": "/admin/assets/app.js?v=20260812-22",
"/admin/assets/js/asset-v.js": "/admin/assets/js/asset-v.js?v=20260812-22",
"/admin/assets/js/core.js": "/admin/assets/js/core.js?v=20260812-22",
"/admin/assets/js/i18n/index.js": "/admin/assets/js/i18n/index.js?v=20260812-22",
"/admin/assets/js/i18n/messages-en.js": "/admin/assets/js/i18n/messages-en.js?v=20260812-22",
"/admin/assets/js/i18n/messages-zh.js": "/admin/assets/js/i18n/messages-zh.js?v=20260812-22",
"/admin/assets/js/main.js": "/admin/assets/js/main.js?v=20260812-22",
"/admin/assets/js/pages/attachments.js": "/admin/assets/js/pages/attachments.js?v=20260812-22",
"/admin/assets/js/pages/audit.js": "/admin/assets/js/pages/audit.js?v=20260812-22",
"/admin/assets/js/pages/authz.js": "/admin/assets/js/pages/authz.js?v=20260812-22",
"/admin/assets/js/pages/legacy-admin.js": "/admin/assets/js/pages/legacy-admin.js?v=20260812-22",
"/admin/assets/js/pages/legacy.js": "/admin/assets/js/pages/legacy.js?v=20260812-22",
"/admin/assets/js/pages/login.js": "/admin/assets/js/pages/login.js?v=20260812-22",
"/admin/assets/js/pages/memory.js": "/admin/assets/js/pages/memory.js?v=20260812-22",
"/admin/assets/js/pages/models.js": "/admin/assets/js/pages/models.js?v=20260812-22",
"/admin/assets/js/pages/plugins.js": "/admin/assets/js/pages/plugins.js?v=20260812-22",
"/admin/assets/js/pages/profile.js": "/admin/assets/js/pages/profile.js?v=20260812-22",
"/admin/assets/js/pages/scheduled-jobs.js": "/admin/assets/js/pages/scheduled-jobs.js?v=20260812-22",
"/admin/assets/js/pages/skills.js": "/admin/assets/js/pages/skills.js?v=20260812-22",
"/admin/assets/js/pages/stack.js": "/admin/assets/js/pages/stack.js?v=20260812-22",
"/admin/assets/js/pages/users.js": "/admin/assets/js/pages/users.js?v=20260812-22",
"/admin/assets/js/pages/workspace-paths.js": "/admin/assets/js/pages/workspace-paths.js?v=20260812-22",
"/admin/assets/js/router.js": "/admin/assets/js/router.js?v=20260812-22",
"/admin/assets/js/state.js": "/admin/assets/js/state.js?v=20260812-22"
"/admin/assets/app.js": "/admin/assets/app.js?v=20260817-wa6",
"/admin/assets/js/asset-v.js": "/admin/assets/js/asset-v.js?v=20260817-wa6",
"/admin/assets/js/core.js": "/admin/assets/js/core.js?v=20260817-wa6",
"/admin/assets/js/i18n/index.js": "/admin/assets/js/i18n/index.js?v=20260817-wa6",
"/admin/assets/js/i18n/messages-en.js": "/admin/assets/js/i18n/messages-en.js?v=20260817-wa6",
"/admin/assets/js/i18n/messages-zh.js": "/admin/assets/js/i18n/messages-zh.js?v=20260817-wa6",
"/admin/assets/js/main.js": "/admin/assets/js/main.js?v=20260817-wa6",
"/admin/assets/js/pages/attachments.js": "/admin/assets/js/pages/attachments.js?v=20260817-wa6",
"/admin/assets/js/pages/audit.js": "/admin/assets/js/pages/audit.js?v=20260817-wa6",
"/admin/assets/js/pages/authz.js": "/admin/assets/js/pages/authz.js?v=20260817-wa6",
"/admin/assets/js/pages/legacy-admin.js": "/admin/assets/js/pages/legacy-admin.js?v=20260817-wa6",
"/admin/assets/js/pages/legacy.js": "/admin/assets/js/pages/legacy.js?v=20260817-wa6",
"/admin/assets/js/pages/login.js": "/admin/assets/js/pages/login.js?v=20260817-wa6",
"/admin/assets/js/pages/memory.js": "/admin/assets/js/pages/memory.js?v=20260817-wa6",
"/admin/assets/js/pages/models.js": "/admin/assets/js/pages/models.js?v=20260817-wa6",
"/admin/assets/js/pages/plugins.js": "/admin/assets/js/pages/plugins.js?v=20260817-wa6",
"/admin/assets/js/pages/profile.js": "/admin/assets/js/pages/profile.js?v=20260817-wa6",
"/admin/assets/js/pages/scheduled-jobs.js": "/admin/assets/js/pages/scheduled-jobs.js?v=20260817-wa6",
"/admin/assets/js/pages/skills.js": "/admin/assets/js/pages/skills.js?v=20260817-wa6",
"/admin/assets/js/pages/stack.js": "/admin/assets/js/pages/stack.js?v=20260817-wa6",
"/admin/assets/js/pages/users.js": "/admin/assets/js/pages/users.js?v=20260817-wa6",
"/admin/assets/js/pages/workspace-paths.js": "/admin/assets/js/pages/workspace-paths.js?v=20260817-wa6",
"/admin/assets/js/router.js": "/admin/assets/js/router.js?v=20260817-wa6",
"/admin/assets/js/state.js": "/admin/assets/js/state.js?v=20260817-wa6"
}
}
</script>
<script type="module" src="/admin/assets/app.js?v=20260812-22" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load admin scripts',h:'Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.'}:{t:'无法加载管理台脚本',h:'请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。'};var s=document.getElementById('admin-boot-splash');if(s){var a=s.querySelector('.admin-boot-splash__title span:last-child');var b=s.querySelector('.admin-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
<script type="module" src="/admin/assets/app.js?v=20260817-wa6" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load admin scripts',h:'Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.'}:{t:'无法加载管理台脚本',h:'请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。'};var s=document.getElementById('admin-boot-splash');if(s){var a=s.querySelector('.admin-boot-splash__title span:last-child');var b=s.querySelector('.admin-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
</body>
</html>

View file

@ -611,6 +611,9 @@ function markPrewarmReminder(reason) {
const base = t("stack.configChangedPrewarm");
runtimePrewarmReminder = why ? `${base} [${why}]` : base;
}
function clearPrewarmReminder() {
runtimePrewarmReminder = "";
}
/**
* Compact row action menu (button + fixed popover).
@ -754,6 +757,7 @@ export {
yesNo,
runtimePrewarmReminder,
markPrewarmReminder,
clearPrewarmReminder,
rowActions,
navigateAdmin,
state,

View file

@ -782,6 +782,30 @@ export default {
"wa.specialist": "Specialist",
"wa.testPush": "Test push",
"wa.whitelistMode": "Whitelist mode (allow by default)",
"wa.sessionTitle": "WhatsApp device session",
"wa.sessionHint": "Show online status; scan to bind or clear the local WhatsApp Web session.",
"wa.sessionRefresh": "Refresh",
"wa.sessionStart": "Start sidecar",
"wa.sessionStop": "Stop sidecar",
"wa.sessionBind": "Scan to bind",
"wa.sessionRebind": "Re-scan bind",
"wa.sessionUnbind": "Unbind device",
"wa.sessionUnbindConfirm": "Stop sidecar and delete local WhatsApp auth (state/auth). You must scan again to rebind. Continue?",
"wa.sessionRebindConfirm": "Clear the old session and show a new QR code. Continue?",
"wa.sessionNotInstalled": "Sidecar not installed. Run whatsapp_install.ps1 first.",
"wa.sessionScanHint": "On your phone: WhatsApp → Linked devices → Link a device, then scan the QR below.",
"wa.sessionQrWaiting": "Waiting for QR…",
"wa.sessionQrStale": "QR may be expired. Click Scan to bind again.",
"wa.lifecycle.unbound": "Not bound",
"wa.lifecycle.bound_stopped": "Bound (sidecar stopped)",
"wa.lifecycle.bound_offline": "Bound (offline / reconnecting)",
"wa.lifecycle.online": "Online",
"wa.lifecycle.awaiting_scan": "Awaiting scan",
"wa.lifecycle.logged_out": "Logged out (rebind required)",
"wa.lifecycle.connecting": "Connecting",
"wa.lifecycle.needs_rebind": "Session invalid (re-scan required)",
"wa.sessionPolling": "Refreshing status…",
"wa.sessionNeedsRebind": "WhatsApp rejected the stored session (e.g. disconnect=405). Click Re-scan bind, or remove the old oclaw linked device on your phone first.",
"wecom.autoBind": "Auto bind on first message",
"wecom.autoBindRole": "Auto bind role",
"wecom.autoBindTenant": "Auto bind team",

View file

@ -782,6 +782,30 @@ export default {
"wa.specialist": "专家",
"wa.testPush": "测试推送",
"wa.whitelistMode": "白名单模式(默认放行,黑名单拒绝)",
"wa.sessionTitle": "WhatsApp 设备会话",
"wa.sessionHint": "查看在线状态,扫码绑定或解绑本机 WhatsApp Web 登录态。",
"wa.sessionRefresh": "刷新状态",
"wa.sessionStart": "启动 sidecar",
"wa.sessionStop": "停止 sidecar",
"wa.sessionBind": "扫码绑定",
"wa.sessionRebind": "重新扫码绑定",
"wa.sessionUnbind": "解绑设备",
"wa.sessionUnbindConfirm": "将停止 sidecar 并删除本机 WhatsApp 登录态(state/auth)。之后需重新扫码绑定,确认继续?",
"wa.sessionRebindConfirm": "将清除旧登录态并重新出二维码,确认继续?",
"wa.sessionNotInstalled": "sidecar 未安装,请先运行 whatsapp_install.ps1",
"wa.sessionScanHint": "请用手机 WhatsApp → 已关联的设备 → 关联设备,扫描下方二维码。",
"wa.sessionQrWaiting": "等待二维码…",
"wa.sessionQrStale": "二维码可能已过期,请点「扫码绑定」刷新。",
"wa.lifecycle.unbound": "未绑定",
"wa.lifecycle.bound_stopped": "已绑定(sidecar 已停)",
"wa.lifecycle.bound_offline": "已绑定(离线重连中)",
"wa.lifecycle.online": "在线",
"wa.lifecycle.awaiting_scan": "待扫码",
"wa.lifecycle.logged_out": "已登出(需重新绑定)",
"wa.lifecycle.connecting": "连接中",
"wa.lifecycle.needs_rebind": "登录态失效(需重新扫码)",
"wa.sessionPolling": "正在刷新状态…",
"wa.sessionNeedsRebind": "WhatsApp 拒绝了当前登录态(disconnect=405 等)。请点「重新扫码绑定」,或在手机端删除旧的 oclaw 关联设备后再扫。",
"wecom.autoBind": "首条消息自动绑定",
"wecom.autoBindRole": "自动绑定角色",
"wecom.autoBindTenant": "自动绑定团队",

View file

@ -1,4 +1,4 @@
import { t, el, tdCell, apiGet, apiPost, CHAT_MEMORY_MODE_KEY, navigateAdmin } from "../core.js";
import { t, el, tdCell, apiGet, apiPost, CHAT_MEMORY_MODE_KEY, navigateAdmin, getRoute } from "../core.js";
async function renderMemory() {
const route = getRoute();

View file

@ -117,7 +117,7 @@ async function renderModels() {
evalDetails.appendChild(evalSummary);
evalDetails.appendChild(evalInner);
let state = null;
let modelsState = null;
let evalPack = null;
let secretsStatus = null;
let expertsState = { items: [] };
@ -179,7 +179,7 @@ async function renderModels() {
}
function labelFor(pid) {
const profiles = (state && state.profiles) || [];
const profiles = (modelsState && modelsState.profiles) || [];
const p = profiles.find((x) => String(x.id) === String(pid));
const base = (p && String(p.name || "").trim()) || pid;
const vr = p && p.visibility_reason;
@ -215,7 +215,7 @@ async function renderModels() {
async function refresh() {
try {
const data = await apiGet("/admin/api/models");
state = data;
modelsState = data;
status.textContent = "";
paint();
try {
@ -239,7 +239,7 @@ async function renderModels() {
}
paintExperts();
} catch (e) {
state = null;
modelsState = null;
evalPack = null;
secretsStatus = null;
expertsState = { ok: false, items: [] };
@ -314,21 +314,21 @@ async function renderModels() {
function paintBindings() {
bindingWrap.innerHTML = "";
if (!state || !state.bindings) return;
const profiles = state.profiles || [];
if (!modelsState || !modelsState.bindings) return;
const profiles = modelsState.profiles || [];
const profileIds = profiles.map((p) => String(p.id));
const roleIds = Array.isArray(state.role_ids) ? state.role_ids : [];
const roleIds = Array.isArray(modelsState.role_ids) ? modelsState.role_ids : [];
roleIds.forEach((rid) => {
const sel = el("select", { class: "input", disabled: !canConfigureBindings });
sel.appendChild(el("option", { value: "", text: t("models.useGlobal") }));
profiles.forEach((p) => {
sel.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) }));
});
const v = String(state.bindings[rid] || "").trim();
const v = String(modelsState.bindings[rid] || "").trim();
sel.value = profileIds.includes(v) ? v : "";
sel.addEventListener("change", async () => {
if (!canConfigureBindings) return;
const next = Object.assign({}, state.bindings);
const next = Object.assign({}, modelsState.bindings);
next[rid] = String(sel.value || "");
try {
await apiPost("/admin/api/models/bindings", { bindings: next });
@ -345,10 +345,10 @@ async function renderModels() {
}
function paintOpsAiBindings() {
if (!state || !state.ops_ai_bindings) return;
const profiles = state.profiles || [];
if (!modelsState || !modelsState.ops_ai_bindings) return;
const profiles = modelsState.profiles || [];
const profileIds = profiles.map((p) => String(p.id));
const roleIds = (Array.isArray(state.role_ids) ? state.role_ids : []).filter((rid) => String(rid || "") !== "manager");
const roleIds = (Array.isArray(modelsState.role_ids) ? modelsState.role_ids : []).filter((rid) => String(rid || "") !== "manager");
const curSpecialist = String(opsAiSpecialistSelect.value || "").trim();
opsAiSpecialistSelect.innerHTML = "";
@ -363,7 +363,7 @@ async function renderModels() {
profiles.forEach((p) => {
opsAiProfileSelect.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) }));
});
const v = String((state.ops_ai_bindings && state.ops_ai_bindings[rid]) || "").trim();
const v = String((modelsState.ops_ai_bindings && modelsState.ops_ai_bindings[rid]) || "").trim();
opsAiProfileSelect.value = profileIds.includes(v) ? v : "";
}
@ -414,22 +414,22 @@ async function renderModels() {
}
function paint() {
if (!state) return;
if (state.ok !== true) {
if (!modelsState) return;
if (modelsState.ok !== true) {
status.textContent = status.textContent || t("models.loadFailed");
return;
}
const profiles = Array.isArray(state.profiles) ? state.profiles : [];
const builtin = String(state.builtin_ollama_profile_id || "");
const profiles = Array.isArray(modelsState.profiles) ? modelsState.profiles : [];
const builtin = String(modelsState.builtin_ollama_profile_id || "");
activeSelect.innerHTML = "";
profiles.forEach((p) => {
activeSelect.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) }));
});
const aid = String(state.active_llm_profile_id || "");
const aid = String(modelsState.active_llm_profile_id || "");
if (profiles.some((p) => String(p.id) === aid)) activeSelect.value = aid;
activeSelect.disabled = !canPickActive;
chatModelSelectorVisibleCb.disabled = !canConfigureChatUi;
chatModelSelectorVisibleCb.checked = state.chat_model_selector_visible !== false;
chatModelSelectorVisibleCb.checked = modelsState.chat_model_selector_visible !== false;
opsAiSpecialistSelect.disabled = !canConfigureBindings;
opsAiProfileSelect.disabled = !canConfigureBindings;
@ -460,7 +460,7 @@ async function renderModels() {
baseInp.value = String(selProf.base_url || "");
thinkingModeCb.checked = !!selProf.thinking_mode_enabled;
reasoningEffortSel.value = String(selProf.reasoning_effort || "");
keyInp.value = String(state.profile_secret || "");
keyInp.value = String(modelsState.profile_secret || "");
rememberCb.checked = !!selProf.has_key;
profName.disabled = !canEditFields;
@ -477,7 +477,7 @@ async function renderModels() {
ollamaHint.textContent = "";
if (m === "openai" || m === "openai_responses") {
ollamaHint.textContent = "";
if (state.has_openai_api_key_env) openaiHint.textContent = t("models.openaiKeyHint");
if (modelsState.has_openai_api_key_env) openaiHint.textContent = t("models.openaiKeyHint");
if (selProf.has_key && !String(keyInp.value || "").trim()) {
warnKey.textContent = t("models.warnKeyInDb");
}
@ -491,7 +491,7 @@ async function renderModels() {
btnDelete.disabled = !canMutateProfiles || !rowMutable || pid === builtin;
modelsGrantsLinkRow.innerHTML = "";
if (state.can_manage_llm_grants) {
if (modelsState.can_manage_llm_grants) {
modelsGrantsLinkRow.style.display = "block";
modelsGrantsLinkRow.appendChild(el("span", { text: `${t("models.grantsNavHint")} ` }));
modelsGrantsLinkRow.appendChild(el("a", { href: "#/api-grants", text: t("models.linkApiGrants") }));
@ -529,7 +529,7 @@ async function renderModels() {
if (!canConfigureBindings) return;
try {
const rid = String(opsAiSpecialistSelect.value || "").trim();
const next = Object.assign({}, (state && state.ops_ai_bindings) || {});
const next = Object.assign({}, (modelsState && modelsState.ops_ai_bindings) || {});
next[rid] = String(opsAiProfileSelect.value || "");
await apiPost("/admin/api/models/ops-ai/bindings", { bindings: next });
await refresh();
@ -554,10 +554,10 @@ async function renderModels() {
const btnSave = el("button", { class: "btn btn--primary", text: t("models.save"), onclick: async () => {
if (!canMutateProfiles) return;
const sel = (state.profiles || []).find((p) => String(p.id) === String(state.active_llm_profile_id || ""));
const sel = (modelsState.profiles || []).find((p) => String(p.id) === String(modelsState.active_llm_profile_id || ""));
if (sel && sel.mutable === false) return;
const pid = String(state.active_llm_profile_id || "");
const builtin = String(state.builtin_ollama_profile_id || "");
const pid = String(modelsState.active_llm_profile_id || "");
const builtin = String(modelsState.builtin_ollama_profile_id || "");
try {
let modeSave = modeSel.value;
if (pid === builtin) modeSave = "ollama";
@ -581,8 +581,8 @@ async function renderModels() {
const btnDelete = el("button", { class: "btn btn--danger", text: t("models.delete"), onclick: async () => {
if (!canMutateProfiles) return;
const pid = String(state.active_llm_profile_id || "");
const builtin = String(state.builtin_ollama_profile_id || "");
const pid = String(modelsState.active_llm_profile_id || "");
const builtin = String(modelsState.builtin_ollama_profile_id || "");
if (pid === builtin) {
status.textContent = t("models.cannotDeleteBuiltin");
return;
@ -709,7 +709,7 @@ async function renderModels() {
await refresh();
if (!state) {
if (!modelsState) {
return el("div", {}, [
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.models") }),
@ -719,32 +719,32 @@ async function renderModels() {
]);
}
if (state.ok === true && Array.isArray(state.profiles) && state.profiles.length === 0) {
if (modelsState.ok === true && Array.isArray(modelsState.profiles) && modelsState.profiles.length === 0) {
const noProfBody = [
el("div", { class: "card__title", text: t("title.models") }),
el("div", { class: "muted", text: t("models.noProfiles") }),
];
if (state.db_path) {
noProfBody.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${state.db_path}` }));
if (modelsState.db_path) {
noProfBody.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${modelsState.db_path}` }));
}
noProfBody.push(status);
return el("div", {}, [el("div", { class: "card" }, noProfBody)]);
}
if (state.ok !== true) {
if (modelsState.ok !== true) {
return el("div", {}, [
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.models") }),
el("div", { class: "muted", text: t("models.loadFailed") }),
el("pre", { class: "pre", text: JSON.stringify(state, null, 2) }),
el("pre", { class: "pre", text: JSON.stringify(modelsState, null, 2) }),
status,
]),
]);
}
const topBits = [readonlyHint, status, modelsGrantsLinkRow];
if (state.db_path) {
topBits.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${state.db_path}` }));
if (modelsState.db_path) {
topBits.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${modelsState.db_path}` }));
}
// Show secret migration helper when legacy secrets exist.
topBits.push(secretsCard);

View file

@ -1,4 +1,4 @@
import { t, el, tdCell, apiGet, apiGetNoHang, apiPost, apiDeleteJson, formatSystemLocalDateTime, renderMetaChips, navigateAdmin, tf } from "../core.js";
import { t, el, tdCell, apiGet, apiGetNoHang, apiPost, apiDeleteJson, formatSystemLocalDateTime, renderMetaChips, navigateAdmin, tf, runtimePrewarmReminder, clearPrewarmReminder } from "../core.js";
async function renderStack() {
const results = await Promise.allSettled([
@ -10,6 +10,7 @@ async function renderStack() {
apiGetNoHang("/admin/api/chat/settings/channel-dispatch/whatsapp"),
apiGetNoHang("/admin/api/whatsapp/groups?tenant_id=default"),
apiGetNoHang("/admin/api/whatsapp/access?tenant_id=default"),
apiGetNoHang("/admin/api/whatsapp/session"),
]);
const scanResp = results[0].status === "fulfilled" ? results[0].value : null;
const prewarmStatusResp = results[1].status === "fulfilled" ? results[1].value : null;
@ -19,6 +20,7 @@ async function renderStack() {
const whatsappDispatchResp = results[5].status === "fulfilled" ? results[5].value : null;
const whatsappGroupsResp = results[6].status === "fulfilled" ? results[6].value : null;
const whatsappAccessResp = results[7].status === "fulfilled" ? results[7].value : null;
const whatsappSessionResp = results[8].status === "fulfilled" ? results[8].value : null;
// If auth failed (401), show a gentle message instead of an infinite spinner.
if (!channelSpecResp && !weixinDispatchResp && !prewarmStatusResp) {
@ -91,6 +93,206 @@ async function renderStack() {
};
const weixinDispatchCard = createChannelDispatchCard("weixin", "Weixin dispatch", weixinDispatchResp || {});
const whatsappDispatchCard = createChannelDispatchCard("whatsapp", "WhatsApp dispatch", whatsappDispatchResp || {});
let waSessionLatest = whatsappSessionResp && typeof whatsappSessionResp === "object" ? whatsappSessionResp : {};
let waSessionBindInProgress = false;
const waSessionHintAlert = el("div", { class: "alert alert--warning u-hidden", text: "" });
const paintWaSession = (st) => {
const s = st && typeof st === "object" ? st : {};
waSessionLatest = s;
const lifecycle = String(s.lifecycle || "unbound").trim() || "unbound";
const lifeLabel = t(`wa.lifecycle.${lifecycle}`) || lifecycle;
const chips = renderMetaChips({
lifecycle: lifeLabel,
connection: String(s.connection || "-"),
phone: String(s.phone || "-"),
sidecar: s.sidecar_running ? `pid ${s.pid || "-"}` : "stopped",
reconnect: Number(s.reconnect_attempt || 0),
});
waSessionStatus.replaceChildren(...Array.from(chips.childNodes));
const detailBits = [
s.display_name ? `name=${s.display_name}` : "",
s.me ? `me=${s.me}` : "",
s.last_error ? `err=${String(s.last_error).slice(0, 120)}` : "",
s.last_disconnect_reason ? `disconnect=${s.last_disconnect_reason}` : "",
s.bridge_updated_at ? `updated=${s.bridge_updated_at}` : "",
].filter(Boolean);
waSessionDetail.textContent = detailBits.join(" | ") || (s.installed ? "" : t("wa.sessionNotInstalled"));
if (lifecycle === "needs_rebind" || lifecycle === "logged_out") {
waSessionHintAlert.textContent = t("wa.sessionNeedsRebind");
waSessionHintAlert.classList.remove("u-hidden");
} else {
waSessionHintAlert.textContent = "";
waSessionHintAlert.classList.add("u-hidden");
}
const qrUrl = String(s.qr_data_url || "").trim();
const awaiting = lifecycle === "awaiting_scan" || waSessionBindInProgress;
waSessionQrWrap.style.display = awaiting ? "" : "none";
waSessionQrHint.textContent = awaiting
? (s.qr_stale ? t("wa.sessionQrStale") : t("wa.sessionScanHint"))
: "";
if (awaiting && qrUrl) {
waSessionQrImg.src = qrUrl;
waSessionQrImg.style.display = "";
waSessionQrWaiting.style.display = "none";
} else if (awaiting) {
waSessionQrImg.removeAttribute("src");
waSessionQrImg.style.display = "none";
waSessionQrWaiting.style.display = "";
waSessionQrWaiting.textContent = t("wa.sessionQrWaiting");
} else {
waSessionQrImg.removeAttribute("src");
waSessionQrImg.style.display = "none";
waSessionQrWaiting.style.display = "none";
}
const bindModes = new Set(["unbound", "logged_out", "awaiting_scan", "needs_rebind"]);
if (waSessionBindBtn) {
waSessionBindBtn.textContent = bindModes.has(lifecycle)
? t("wa.sessionBind")
: t("wa.sessionRebind");
waSessionBindBtn.disabled = Boolean(waSessionBindInProgress);
}
};
const waSessionStatus = el("div", { class: "muted" });
const waSessionDetail = el("div", { class: "muted u-mt-8" });
const waSessionBindBtn = el("button", {
class: "btn btn--primary",
text: t("wa.sessionBind"),
});
const waSessionQrImg = el("img", {
alt: "WhatsApp QR",
style: "width:240px;height:240px;background:#fff;border:1px solid var(--border, #ddd);display:none;",
});
const waSessionQrWaiting = el("div", { class: "muted", style: "display:none;" });
const waSessionQrHint = el("div", { class: "card__hint" });
const waSessionQrWrap = el("div", { class: "u-mt-10", style: "display:none;" }, [
waSessionQrHint,
waSessionQrImg,
waSessionQrWaiting,
]);
const waSessionActionStatus = el("div", { class: "muted u-mt-8", text: "" });
const pollWaSession = async (opts = {}) => {
const maxMs = Number(opts.maxMs || 60000);
const stepMs = Number(opts.stepMs || 2000);
const stopWhen = opts.stopWhen || ((st) => {
const life = String((st && st.lifecycle) || "");
return ["online", "awaiting_scan", "needs_rebind", "logged_out", "bound_stopped", "unbound"].includes(life);
});
waSessionActionStatus.textContent = t("wa.sessionPolling");
let waited = 0;
let latest = waSessionLatest;
while (waited < maxMs) {
const st = await apiGet("/admin/api/whatsapp/session");
paintWaSession(st);
latest = st;
if (stopWhen(st)) break;
await new Promise((r) => setTimeout(r, stepMs));
waited += stepMs;
}
waSessionActionStatus.textContent = "";
return latest;
};
const withWaSessionAction = async (fn, opts = {}) => {
waSessionActionStatus.textContent = "…";
try {
const resp = await fn();
const st = (resp && resp.status) || resp || {};
paintWaSession(st.ok === false && !st.lifecycle ? (await apiGet("/admin/api/whatsapp/session")) : st);
if (resp && resp.ok === false) {
waSessionActionStatus.textContent = String(resp.error || resp.stderr || "failed");
} else {
waSessionActionStatus.textContent = "ok";
}
if (opts.poll !== false && resp && resp.ok !== false) {
await pollWaSession(opts.poll || {});
}
return resp;
} catch (err) {
waSessionActionStatus.textContent = String(err);
throw err;
}
};
const waSessionRefreshBtn = el("button", {
class: "btn",
text: t("wa.sessionRefresh"),
onclick: async () => {
await withWaSessionAction(async () => apiGet("/admin/api/whatsapp/session"), { poll: false });
},
});
const waSessionStartBtn = el("button", {
class: "btn",
text: t("wa.sessionStart"),
onclick: async () => {
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/start", {}));
},
});
const waSessionStopBtn = el("button", {
class: "btn",
text: t("wa.sessionStop"),
onclick: async () => {
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/stop", { force: true }), { poll: false });
},
});
const waSessionBindBtnClick = async () => {
const curLife = String((waSessionLatest && waSessionLatest.lifecycle) || "").trim();
const autoClear = ["needs_rebind", "logged_out"].includes(curLife);
const needClear = autoClear || (curLife && !["unbound", "awaiting_scan"].includes(curLife));
if (needClear && !autoClear && !window.confirm(t("wa.sessionRebindConfirm"))) return;
waSessionBindInProgress = true;
paintWaSession(waSessionLatest);
try {
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/bind", {
clear_auth: Boolean(needClear),
}), {
poll: {
maxMs: 120000,
stopWhen: (st) => {
const life = String((st && st.lifecycle) || "");
if (life === "online") return true;
return life === "awaiting_scan" && Boolean(st && (st.qr_data_url || st.qr));
},
},
});
} finally {
waSessionBindInProgress = false;
paintWaSession(waSessionLatest);
}
};
waSessionBindBtn.onclick = waSessionBindBtnClick;
const waSessionUnbindBtn = el("button", {
class: "btn btn--danger",
text: t("wa.sessionUnbind"),
onclick: async () => {
if (!window.confirm(t("wa.sessionUnbindConfirm"))) return;
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/unbind", {}), { poll: false });
},
});
const whatsappSessionCard = el("div", { class: "card" }, [
el("div", { class: "card__head" }, [
el("div", { class: "card__headline" }, [
el("div", { class: "card__title", text: t("wa.sessionTitle") }),
el("div", { class: "card__subtitle", text: t("wa.sessionHint") }),
]),
el("div", { class: "card__actions" }, [
waSessionRefreshBtn,
waSessionStartBtn,
waSessionStopBtn,
waSessionBindBtn,
waSessionUnbindBtn,
]),
]),
waSessionStatus,
waSessionHintAlert,
waSessionDetail,
waSessionQrWrap,
waSessionActionStatus,
]);
paintWaSession(whatsappSessionResp || {});
if (whatsappSessionResp && whatsappSessionResp.sidecar_running) {
const initLife = String(whatsappSessionResp.lifecycle || "");
if (["connecting", "bound_offline"].includes(initLife)) {
void pollWaSession({ maxMs: 30000 });
}
}
const waGroups = Array.isArray(whatsappGroupsResp && whatsappGroupsResp.items) ? whatsappGroupsResp.items : [];
const waBinding = (whatsappGroupsResp && whatsappGroupsResp.binding) || {};
const waGroupSel = el(
@ -605,7 +807,7 @@ async function renderStack() {
}
prewarmStatus.textContent =
t("auto.prewarm_running_in_background_refreshing_this_page_when_");
runtimePrewarmReminder = "";
clearPrewarmReminder();
const maxWaitMs = 120000;
const stepMs = 400;
let waited = 0;
@ -696,6 +898,7 @@ async function renderStack() {
weixinDispatchCard,
whatsappDispatchCard,
]),
whatsappSessionCard,
whatsappAccessCard,
whatsappAlertBindingCard,
el("div", { class: "card" }, [