feat(whatsapp): add access control with quote-based admin approval

Introduce whitelist/blacklist gating, pending requests, Admin UI management, and stanza-mapped quote approval so admins can approve via WhatsApp DM without blocking normal LLM chat.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-01 11:36:58 +08:00
parent 8781a200c9
commit 1ea13a205d
13 changed files with 3048 additions and 30 deletions

View file

@ -1587,6 +1587,7 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
ctx = resolve_auth(store, authorization)
_require_administrator_chat_viewer(ctx)
ch = _normalize_channel_dispatch_channel(channel)
default_lang = "en" if ch == "whatsapp" else "auto"
interaction_mode = normalize_interaction_mode(
store.get_setting(_channel_dispatch_interaction_key(ch)) or "expert"
)
@ -1594,7 +1595,7 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
store.get_setting(_channel_dispatch_specialist_key(ch)) or "generalist"
)
lang = _normalize_channel_dispatch_lang(
store.get_setting(_channel_dispatch_lang_key(ch)) or "auto"
store.get_setting(_channel_dispatch_lang_key(ch)) or default_lang
)
specialist = _apply_specialist_flags(store, specialist)
return {

View file

@ -1413,6 +1413,325 @@ def build_admin_router() -> APIRouter:
)
return {"ok": True, "outbound_id": msg_id}
@router.get("/admin/api/whatsapp/access")
def api_whatsapp_access_get(
tenant_id: str = Query(default="default"),
account_id: str = Query(default=""),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
tid = resolve_whatsapp_tenant_id(store, account_id=aid)
legacy_tid = str(tenant_id or "default").strip()
# Be defensive: older DBs/gateways may not have the new tables yet.
# Do not break the entire Runtime page; return empty defaults instead.
try:
cfg = store.get_whatsapp_access_config(tenant_id=tid, account_id=aid) or {}
if not cfg and legacy_tid and legacy_tid != tid:
cfg = store.get_whatsapp_access_config(tenant_id=legacy_tid, account_id=aid) or {}
except Exception:
cfg = {}
contacts_by_phone: dict[str, dict[str, Any]] = {}
try:
from runtime.extensions.whatsapp.access_control import contact_phone_key
priority = {"admin": 4, "blacklist": 3, "whitelist": 2}
def _contact_rank(row: dict[str, Any]) -> int:
lt = str(row.get("list_type") or "").strip().lower()
return int(priority.get(lt, 0))
for source_tid in (tid, legacy_tid):
if not source_tid:
continue
for row in store.list_whatsapp_contacts(tenant_id=source_tid, account_id=aid, limit=500):
phone_key = contact_phone_key(row)
if not phone_key:
continue
prev = contacts_by_phone.get(phone_key)
if not prev or _contact_rank(row) > _contact_rank(prev):
contacts_by_phone[phone_key] = row
contacts = list(contacts_by_phone.values())
contacts = [
row
for row in contacts
if str(row.get("list_type") or "").strip().lower() in {"admin", "whitelist", "blacklist"}
]
except Exception:
contacts = []
pending_by_id: dict[str, dict[str, Any]] = {}
try:
for source_tid in (tid, legacy_tid):
if not source_tid:
continue
for row in store.list_whatsapp_access_pending(
tenant_id=source_tid, account_id=aid, status="pending", limit=50
):
pid = str(row.get("id") or "").strip()
if pid:
pending_by_id[pid] = row
pending = list(pending_by_id.values())
except Exception:
pending = []
denied_by_id: dict[str, dict[str, Any]] = {}
try:
for source_tid in (tid, legacy_tid):
if not source_tid:
continue
for row in store.list_whatsapp_access_pending(
tenant_id=source_tid, account_id=aid, status="denied", limit=50
):
pid = str(row.get("id") or "").strip()
if pid:
denied_by_id[pid] = row
denied = list(denied_by_id.values())
except Exception:
denied = []
return {
"ok": True,
"config": {
"tenant_id": tid,
"account_id": aid,
"access_mode": str(cfg.get("access_mode") or "blacklist"),
"lang": str(cfg.get("lang") or "en"),
},
"contacts": contacts,
"pending": pending,
"denied": denied,
}
@router.post("/admin/api/whatsapp/access/config")
def api_whatsapp_access_config_upsert(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id
payload = payload or {}
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
aid = str(payload.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
tid = resolve_whatsapp_tenant_id(store, account_id=aid)
access_mode = str(payload.get("access_mode") or "blacklist").strip().lower()
lang = str(payload.get("lang") or "en").strip().lower()
cfg = store.upsert_whatsapp_access_config(
tenant_id=tid,
account_id=aid,
access_mode=access_mode,
lang=lang,
)
return {"ok": True, "config": cfg}
@router.post("/admin/api/whatsapp/access/contacts")
def api_whatsapp_access_contact_upsert(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
from runtime.extensions.whatsapp.access_control import (
normalize_whatsapp_phone,
resolve_sender_phone,
)
from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id
payload = payload or {}
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
aid = str(payload.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
tid = resolve_whatsapp_tenant_id(store, account_id=aid)
target_raw = str(payload.get("phone") or payload.get("external_user_id") or "").strip()
if not target_raw:
return {"ok": False, "error": "phone_required"}
try:
phone_val = normalize_whatsapp_phone(target_raw)
except Exception:
return {"ok": False, "error": "invalid_phone"}
list_type = str(payload.get("list_type") or "").strip().lower()
if list_type not in {"admin", "whitelist", "blacklist"}:
return {"ok": False, "error": "invalid_list_type"}
contact = store.apply_whatsapp_contact_access(
tenant_id=tid,
account_id=aid,
external_user_id=phone_val,
push_name=str(payload.get("push_name") or ""),
phone=phone_val,
list_type=list_type,
notes=str(payload.get("notes") or ""),
)
resolved_by = str(ctx.get("user_id") or "")
pending_status = "approved" if list_type in {"admin", "whitelist"} else "denied"
legacy_tid = str(payload.get("tenant_id") or "default").strip()
extra_tids = [legacy_tid] if legacy_tid and legacy_tid != tid else []
store.resolve_whatsapp_pending_for_sender(
tenant_id=tid,
account_id=aid,
external_user_id=phone_val,
resolved_by=resolved_by,
status=pending_status,
extra_tenant_ids=extra_tids,
)
return {"ok": True, "contact": contact}
@router.delete("/admin/api/whatsapp/access/contacts")
def api_whatsapp_access_contact_delete(
tenant_id: str = Query(default="default"),
account_id: str = Query(default=""),
phone: str = Query(default=""),
external_user_id: str = Query(default=""),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
from runtime.extensions.whatsapp.access_control import normalize_whatsapp_phone
from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
tid = resolve_whatsapp_tenant_id(store, account_id=aid)
key = str(phone or external_user_id or "").strip()
if not key:
return {"ok": False, "error": "phone_required"}
try:
phone_val = normalize_whatsapp_phone(key)
except Exception:
return {"ok": False, "error": "invalid_phone"}
legacy_tid = str(tenant_id or "default").strip()
extra_tids = [legacy_tid] if legacy_tid and legacy_tid != tid else []
deleted_count = store.delete_whatsapp_contact_aliases(
tenant_id=tid,
account_id=aid,
external_user_id=phone_val,
extra_tenant_ids=extra_tids,
)
return {"ok": True, "deleted": deleted_count > 0, "deleted_count": deleted_count}
@router.post("/admin/api/whatsapp/access/pending/resolve")
def api_whatsapp_access_pending_resolve(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
from runtime.extensions.whatsapp.access_control import resolve_sender_phone
from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id
body = payload or {}
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
aid = str(body.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
tid = resolve_whatsapp_tenant_id(store, account_id=aid)
legacy_tid = str(body.get("tenant_id") or "default").strip()
extra_tids = [legacy_tid] if legacy_tid and legacy_tid != tid else []
pending_id = str(body.get("pending_id") or "").strip()
action = str(body.get("action") or "").strip().lower()
if not pending_id:
return {"ok": False, "error": "pending_id_required"}
if action not in {"approve", "deny", "dismiss"}:
return {"ok": False, "error": "invalid_action"}
items: list[dict[str, Any]] = []
seen: set[str] = set()
for source_tid in (tid, *extra_tids):
for status in ("pending", "denied"):
for row in store.list_whatsapp_access_pending(
tenant_id=source_tid, account_id=aid, status=status, limit=200
):
pid = str(row.get("id") or "")
if pid and pid not in seen:
seen.add(pid)
items.append(row)
row = next((x for x in items if str(x.get("id") or "") == pending_id), None)
if not row:
return {"ok": False, "error": "pending_not_found"}
external_user_id = str(row.get("external_user_id") or "").strip()
push_name = str(row.get("push_name") or "").strip()
resolved_by = str(ctx.get("user_id") or "")
if action == "dismiss":
deleted = store.delete_whatsapp_access_pending(pending_id=pending_id)
return {"ok": deleted, "action": "dismiss", "deleted": deleted}
if action == "approve":
phone_val = str(row.get("phone") or "").strip() or resolve_sender_phone(external_user_id)
if not phone_val:
return {"ok": False, "error": "invalid_phone"}
store.apply_whatsapp_contact_access(
tenant_id=tid,
account_id=aid,
external_user_id=phone_val,
push_name=push_name,
phone=phone_val,
list_type="whitelist",
)
changed = store.resolve_whatsapp_access_pending(
pending_id=pending_id,
status="approved",
resolved_by=resolved_by,
from_statuses=("pending", "denied"),
)
store.resolve_whatsapp_pending_for_sender(
tenant_id=tid,
account_id=aid,
external_user_id=phone_val,
resolved_by=resolved_by,
status="approved",
extra_tenant_ids=extra_tids,
)
return {
"ok": bool(changed),
"action": "approve",
"phone": phone_val,
}
phone_val = str(row.get("phone") or "").strip() or resolve_sender_phone(external_user_id)
if phone_val:
store.apply_whatsapp_contact_access(
tenant_id=tid,
account_id=aid,
external_user_id=phone_val,
push_name=push_name,
phone=phone_val,
list_type="blacklist",
)
changed = store.resolve_whatsapp_access_pending(
pending_id=pending_id,
status="denied",
resolved_by=resolved_by,
from_statuses=("pending",),
)
store.resolve_whatsapp_pending_for_sender(
tenant_id=tid,
account_id=aid,
external_user_id=phone_val or external_user_id,
resolved_by=resolved_by,
status="denied",
extra_tenant_ids=extra_tids,
)
return {"ok": bool(changed), "action": "deny", "phone": phone_val or external_user_id}
@router.delete("/admin/api/whatsapp/access/pending")
def api_whatsapp_access_pending_delete(
pending_id: str = Query(default=""),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
pid = str(pending_id or "").strip()
if not pid:
return {"ok": False, "error": "pending_id_required"}
deleted = store.delete_whatsapp_access_pending(
pending_id=pid,
statuses=("pending", "denied"),
)
return {"ok": deleted, "deleted": deleted}
@router.get("/admin/api/users")
def api_users(
tenant_id: str,

View file

@ -1166,6 +1166,30 @@ async function apiGet(path) {
return await res.json();
}
async function apiGetOptional(path) {
try {
return await apiGet(path);
} catch (err) {
console.warn(`apiGetOptional failed path=${String(path || "")} err=${String(err)}`);
return null;
}
}
async function apiGetNoHang(path) {
const url = resolveAdminApiUrl(path);
const token = getStoredAuthToken();
const headers = { "accept": "application/json" };
if (token) headers["authorization"] = `Bearer ${token}`;
const res = await fetch(url, { headers });
if (res.status === 401) {
scheduleReauthAfter401(url);
// Returning null avoids Promise.all / await from hanging forever.
return null;
}
if (!res.ok) throw new Error(`GET ${url} ${res.status}`);
return await res.json();
}
async function apiPost(path, body) {
const url = resolveAdminApiUrl(path);
const token = getStoredAuthToken();
@ -1544,17 +1568,36 @@ function markPrewarmReminder(reason) {
}
async function renderStack() {
const [st, anomaliesResp, scanResp, prewarmStatusResp, prewarmPromptsResp, channelSpecResp, weixinDispatchResp, whatsappDispatchResp, whatsappGroupsResp] = await Promise.all([
apiGet("/admin/api/stack/status"),
apiGet("/admin/api/runtime/anomalies"),
apiGet("/admin/api/runtime/scan-artifacts"),
apiGet("/admin/api/runtime/prewarm/status"),
apiGet("/admin/api/runtime/prewarm/prompts?role=manager"),
apiGet("/admin/api/chat/settings/specialist-flags"),
apiGet("/admin/api/chat/settings/channel-dispatch/weixin"),
apiGet("/admin/api/chat/settings/channel-dispatch/whatsapp"),
apiGet("/admin/api/whatsapp/groups?tenant_id=default"),
const results = await Promise.allSettled([
apiGetNoHang("/admin/api/stack/status"),
apiGetNoHang("/admin/api/runtime/anomalies"),
apiGetNoHang("/admin/api/runtime/scan-artifacts"),
apiGetNoHang("/admin/api/runtime/prewarm/status"),
apiGetNoHang("/admin/api/runtime/prewarm/prompts?role=manager"),
apiGetNoHang("/admin/api/chat/settings/specialist-flags"),
apiGetNoHang("/admin/api/chat/settings/channel-dispatch/weixin"),
apiGetNoHang("/admin/api/chat/settings/channel-dispatch/whatsapp"),
apiGetNoHang("/admin/api/whatsapp/groups?tenant_id=default"),
apiGetNoHang("/admin/api/whatsapp/access?tenant_id=default"),
]);
const st = results[0].status === "fulfilled" ? results[0].value : null;
const anomaliesResp = results[1].status === "fulfilled" ? results[1].value : null;
const scanResp = results[2].status === "fulfilled" ? results[2].value : null;
const prewarmStatusResp = results[3].status === "fulfilled" ? results[3].value : null;
const prewarmPromptsResp = results[4].status === "fulfilled" ? results[4].value : null;
const channelSpecResp = results[5].status === "fulfilled" ? results[5].value : null;
const weixinDispatchResp = results[6].status === "fulfilled" ? results[6].value : null;
const whatsappDispatchResp = results[7].status === "fulfilled" ? results[7].value : null;
const whatsappGroupsResp = results[8].status === "fulfilled" ? results[8].value : null;
const whatsappAccessResp = results[9].status === "fulfilled" ? results[9].value : null;
// If auth failed (401), show a gentle message instead of an infinite spinner.
if (!st) {
return el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.stack") || "Stack" }),
el("div", { class: "muted", text: currentLang === "zh" ? "未登录或会话已过期,请重新登录。" : "Not logged in or session expired. Please log in again." }),
]);
}
const requiredServices = ["gateway", "channel:wecom"];
const runningNames = new Set(
(Array.isArray(st.items) ? st.items : [])
@ -1680,6 +1723,353 @@ async function renderStack() {
? `binding=${waBinding.group_jid} enabled=${Boolean(waBinding.enabled)}`
: (currentLang === "zh" ? "尚未绑定告警群" : "No alert group bound"),
});
const waAccessCfg = (whatsappAccessResp && whatsappAccessResp.config) || {};
const waContacts = Array.isArray(whatsappAccessResp && whatsappAccessResp.contacts) ? whatsappAccessResp.contacts : [];
const waPending = Array.isArray(whatsappAccessResp && whatsappAccessResp.pending) ? whatsappAccessResp.pending : [];
const waDenied = Array.isArray(whatsappAccessResp && whatsappAccessResp.denied) ? whatsappAccessResp.denied : [];
const waPhoneDisplay = (row) => {
const phone = String((row && row.phone) || "").trim();
if (phone) return phone;
const jid = String((row && row.external_user_id) || "").trim();
const base = jid.split("@")[0] || "";
const digits = base.replace(/\D/g, "");
return digits || base || "-";
};
const waAccessModeSel = el("select", { class: "input" }, [
el("option", {
value: "blacklist",
text: currentLang === "zh" ? "黑名单模式(默认拒绝,白名单放行)" : "Blacklist mode (deny by default)",
selected: String(waAccessCfg.access_mode || "blacklist") === "blacklist" ? "selected" : undefined,
}),
el("option", {
value: "whitelist",
text: currentLang === "zh" ? "白名单模式(默认放行,黑名单拒绝)" : "Whitelist mode (allow by default)",
selected: String(waAccessCfg.access_mode || "") === "whitelist" ? "selected" : undefined,
}),
]);
const waAccessLangSel = el("select", { class: "input" }, [
el("option", { value: "en", text: "English", selected: String(waAccessCfg.lang || "en") === "en" ? "selected" : undefined }),
el("option", { value: "zh", text: currentLang === "zh" ? "中文" : "Chinese", selected: String(waAccessCfg.lang || "") === "zh" ? "selected" : undefined }),
]);
const waCounts = { admin: 0, whitelist: 0, blacklist: 0 };
waContacts.forEach((c) => {
const lt = String((c && c.list_type) || "").trim().toLowerCase();
if (lt === "admin") waCounts.admin += 1;
else if (lt === "whitelist") waCounts.whitelist += 1;
else if (lt === "blacklist") waCounts.blacklist += 1;
});
const waAccessStatus = el("div", {
class: "muted",
text: `mode=${String(waAccessCfg.access_mode || "blacklist")} lang=${String(waAccessCfg.lang || "en")} admin=${waCounts.admin} whitelist=${waCounts.whitelist} blacklist=${waCounts.blacklist} pending=${waPending.length} denied=${waDenied.length}`,
});
const waContactFilterSel = el("select", { class: "input" }, [
el("option", { value: "all", text: currentLang === "zh" ? "全部" : "All" }),
el("option", { value: "admin", text: currentLang === "zh" ? "管理员" : "Admin" }),
el("option", { value: "whitelist", text: currentLang === "zh" ? "白名单" : "Whitelist" }),
el("option", { value: "blacklist", text: currentLang === "zh" ? "黑名单" : "Blacklist" }),
]);
const waContactsTbody = el("tbody", {});
const waContactPhone = (row) => {
const phone = String((row && row.phone) || "").trim();
if (phone) return phone;
return waPhoneDisplay(row);
};
const waSaveContact = async (phone, pushName, listType) => {
const phoneVal = String(phone || "").trim();
const list_type = String(listType || "").trim().toLowerCase();
if (!phoneVal) return;
if (!list_type) {
window.alert(currentLang === "zh" ? "请选择类型" : "Please select a type");
return;
}
try {
const resp = await apiPost("/admin/api/whatsapp/access/contacts", {
phone: phoneVal,
push_name: String(pushName || "").trim(),
list_type,
});
if (!resp || resp.ok === false) {
window.alert(String((resp && resp.error) || (currentLang === "zh" ? "修改失败" : "Update failed")));
return;
}
router();
} catch (err) {
window.alert(String(err));
}
};
const renderWaContactRows = (filterValue) => {
const filter = String(filterValue || "all").trim().toLowerCase() || "all";
const rows = waContacts
.filter((c) => {
const lt = String((c && c.list_type) || "").trim().toLowerCase();
if (filter === "all") return true;
return lt === filter;
})
.map((c) => {
const name = String((c && c.push_name) || "");
const lt = String((c && c.list_type) || "").trim().toLowerCase();
const phone = waContactPhone(c);
const typeSel = el("select", { class: "input" }, [
el("option", { value: "admin", text: currentLang === "zh" ? "管理员" : "Admin" }),
el("option", { value: "whitelist", text: currentLang === "zh" ? "白名单" : "Whitelist" }),
el("option", { value: "blacklist", text: currentLang === "zh" ? "黑名单" : "Blacklist" }),
]);
typeSel.value = lt || "whitelist";
const actionBtns = [
el("button", {
class: "btn",
text: currentLang === "zh" ? "修改" : "Update",
onclick: async () => {
await waSaveContact(phone, name, String(typeSel.value || "").trim());
},
}),
];
if (lt === "blacklist") {
actionBtns.push(el("span", { text: " " }));
actionBtns.push(el("button", {
class: "btn btn--primary",
text: currentLang === "zh" ? "加白名单" : "Whitelist",
onclick: async () => {
await waSaveContact(phone, name, "whitelist");
},
}));
}
actionBtns.push(el("span", { text: " " }));
actionBtns.push(el("button", {
class: "btn btn--danger",
text: currentLang === "zh" ? "删除" : "Remove",
onclick: async () => {
try {
const resp = await apiDeleteJson(
`/admin/api/whatsapp/access/contacts?phone=${encodeURIComponent(phone)}`,
);
if (resp && resp.deleted === false) {
window.alert(currentLang === "zh" ? "未找到可删除的联系人" : "Contact not found");
return;
}
router();
} catch (err) {
window.alert(String(err));
}
},
}));
return el("tr", {}, [
el("td", { "data-copy-disabled": "1" }, [typeSel]),
el("td", { text: name || "-" }),
el("td", { text: phone || "-" }),
el("td", { "data-copy-disabled": "1" }, actionBtns),
]);
});
waContactsTbody.replaceChildren(
...(rows.length
? rows
: [el("tr", {}, [el("td", { colspan: "4", text: currentLang === "zh" ? "暂无联系人" : "No contacts" })])]),
);
};
waContactFilterSel.addEventListener("change", () => {
renderWaContactRows(String(waContactFilterSel.value || "all"));
});
renderWaContactRows("all");
const waContactPhoneInput = el("input", { class: "input", placeholder: currentLang === "zh" ? "电话,如 +8615601877957" : "Phone, e.g. +8615601877957" });
const waContactNameInput = el("input", { class: "input", placeholder: currentLang === "zh" ? "显示名(可选)" : "Display name (optional)" });
const waContactTypeSel = el("select", { class: "input" }, [
el("option", { value: "admin", text: currentLang === "zh" ? "管理员" : "Admin" }),
el("option", { value: "whitelist", text: currentLang === "zh" ? "白名单" : "Whitelist" }),
el("option", { value: "blacklist", text: currentLang === "zh" ? "黑名单" : "Blacklist" }),
]);
const waPendingPickSel = el(
"select",
{ class: "input" },
[
el("option", { value: "", text: currentLang === "zh" ? "从待审批选择联系人…" : "Pick from pending requests…" }),
...waPending.map((p, idx) => {
const phone = waPhoneDisplay(p);
const name = String((p && p.push_name) || "").trim();
const label = name ? `${name} (${phone})` : phone;
return el("option", { value: String(idx), text: label || phone });
}),
],
);
waPendingPickSel.addEventListener("change", () => {
const idx = Number(String(waPendingPickSel.value || "").trim());
if (!Number.isFinite(idx) || idx < 0 || idx >= waPending.length) return;
const picked = waPending[idx] || {};
const phone = waPhoneDisplay(picked);
const name = String((picked && picked.push_name) || "").trim();
if (phone && phone !== "-") waContactPhoneInput.value = phone;
waContactNameInput.value = name || "";
});
const waResolvePending = async (pendingId, action) => {
if (!pendingId) return;
try {
const resp = await apiPost("/admin/api/whatsapp/access/pending/resolve", {
pending_id: pendingId,
action,
});
if (!resp || resp.ok === false) {
window.alert(String((resp && resp.error) || (currentLang === "zh" ? "操作失败" : "Request failed")));
return;
}
router();
} catch (err) {
window.alert(String(err));
}
};
const waDeletePending = async (pendingId) => {
if (!pendingId) return;
try {
const resp = await apiDeleteJson(
`/admin/api/whatsapp/access/pending?pending_id=${encodeURIComponent(pendingId)}`,
);
if (!resp || resp.ok === false) {
window.alert(String((resp && resp.error) || (currentLang === "zh" ? "删除失败" : "Delete failed")));
return;
}
router();
} catch (err) {
window.alert(String(err));
}
};
const waDeniedRows = waDenied.map((p) => {
const pendingId = String((p && p.id) || "").trim();
const phone = waPhoneDisplay(p);
const name = String((p && p.push_name) || "").trim();
return el("tr", {}, [
el("td", { text: name || "-" }),
el("td", { text: phone }),
el("td", { text: String((p && p.request_text) || "").slice(0, 80) }),
el("td", { text: String((p && p.resolved_at) || p.created_at || "") }),
el("td", {}, [
el("button", {
class: "btn btn--primary",
text: currentLang === "zh" ? "加白名单" : "Whitelist",
onclick: async () => { await waResolvePending(pendingId, "approve"); },
}),
el("span", { text: " " }),
el("button", {
class: "btn btn--danger",
text: currentLang === "zh" ? "删除" : "Delete",
onclick: async () => { await waDeletePending(pendingId); },
}),
]),
]);
});
const waPendingRows = waPending.map((p) => {
const pendingId = String((p && p.id) || "").trim();
return el("tr", {}, [
el("td", { text: String((p && p.push_name) || "-") }),
el("td", { text: waPhoneDisplay(p) }),
el("td", { text: String((p && p.external_user_id) || "") }),
el("td", { text: String((p && p.request_text) || "").slice(0, 80) }),
el("td", { text: String((p && p.created_at) || "") }),
el("td", {}, [
el("button", {
class: "btn btn--primary",
text: currentLang === "zh" ? "通过" : "Approve",
onclick: async () => { await waResolvePending(pendingId, "approve"); },
}),
el("span", { text: " " }),
el("button", {
class: "btn",
text: currentLang === "zh" ? "拒绝" : "Deny",
onclick: async () => { await waResolvePending(pendingId, "deny"); },
}),
el("span", { text: " " }),
el("button", {
class: "btn btn--danger",
text: currentLang === "zh" ? "删除" : "Delete",
onclick: async () => { await waDeletePending(pendingId); },
}),
]),
]);
});
const whatsappAccessCard = el("div", { class: "card" }, [
el("div", { class: "card__title", text: currentLang === "zh" ? "WhatsApp 访问控制" : "WhatsApp access control" }),
el("div", { class: "muted", text: currentLang === "zh" ? "默认黑名单模式:未授权用户进入待审批;拒绝后进入黑名单/已拒绝列表,可一键加白名单。" : "Default blacklist mode: unauthorized users go to Pending; denied users go to blacklist/denied list and can be whitelisted." }),
el("div", { class: "row" }, [
el("label", { text: currentLang === "zh" ? "模式" : "Mode" }),
waAccessModeSel,
el("label", { text: currentLang === "zh" ? "提示语言" : "Message lang" }),
waAccessLangSel,
el("button", {
class: "btn btn--primary",
text: currentLang === "zh" ? "保存配置" : "Save config",
onclick: async () => {
const resp = await apiPost("/admin/api/whatsapp/access/config", {
tenant_id: "default",
access_mode: String(waAccessModeSel.value || "blacklist"),
lang: String(waAccessLangSel.value || "en"),
});
const cfg = (resp && resp.config) || {};
waAccessStatus.textContent = `mode=${String(cfg.access_mode || "blacklist")} lang=${String(cfg.lang || "en")}`;
},
}),
]),
waAccessStatus,
el("div", { class: "row" }, [
el("label", { text: currentLang === "zh" ? "筛选" : "Filter" }),
waContactFilterSel,
]),
el("div", { class: "row" }, [waPendingPickSel]),
el("div", { class: "row" }, [waContactPhoneInput, waContactNameInput, waContactTypeSel]),
el("div", { class: "row" }, [
el("button", {
class: "btn",
text: currentLang === "zh" ? "添加联系人" : "Add contact",
onclick: async () => {
const phone = String(waContactPhoneInput.value || "").trim();
if (!phone) return;
try {
const resp = await apiPost("/admin/api/whatsapp/access/contacts", {
phone,
push_name: String(waContactNameInput.value || "").trim(),
list_type: String(waContactTypeSel.value || "whitelist"),
});
if (!resp || resp.ok === false) {
window.alert(String((resp && resp.error) || (currentLang === "zh" ? "添加失败" : "Add failed")));
return;
}
router();
} catch (err) {
window.alert(String(err));
}
},
}),
]),
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: currentLang === "zh" ? "类型" : "Type" }),
el("th", { text: currentLang === "zh" ? "名称" : "Name" }),
el("th", { text: currentLang === "zh" ? "电话" : "Phone" }),
el("th", { text: currentLang === "zh" ? "操作" : "Action" }),
])]),
waContactsTbody,
]),
el("div", { class: "card__title", text: currentLang === "zh" ? "待审批请求" : "Pending requests" }),
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: currentLang === "zh" ? "名称" : "Name" }),
el("th", { text: currentLang === "zh" ? "电话" : "Phone" }),
el("th", { text: "JID" }),
el("th", { text: currentLang === "zh" ? "消息" : "Message" }),
el("th", { text: currentLang === "zh" ? "时间" : "Time" }),
el("th", { text: currentLang === "zh" ? "操作" : "Action" }),
])]),
el("tbody", {}, waPendingRows.length ? waPendingRows : [el("tr", {}, [el("td", { colspan: "6", text: currentLang === "zh" ? "无待审批" : "None" })])]),
]),
el("div", { class: "card__title", text: currentLang === "zh" ? "已拒绝 / 黑名单记录" : "Denied / blacklist records" }),
el("div", { class: "muted", text: currentLang === "zh" ? "点「拒绝」后用户会进入黑名单联系人,并保留在此列表;可一键加白名单恢复访问。" : "Denied users are blacklisted and listed here; use Whitelist to restore access." }),
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: currentLang === "zh" ? "名称" : "Name" }),
el("th", { text: currentLang === "zh" ? "电话" : "Phone" }),
el("th", { text: currentLang === "zh" ? "消息" : "Message" }),
el("th", { text: currentLang === "zh" ? "拒绝时间" : "Denied at" }),
el("th", { text: currentLang === "zh" ? "操作" : "Action" }),
])]),
el("tbody", {}, waDeniedRows.length ? waDeniedRows : [el("tr", {}, [el("td", { colspan: "5", text: currentLang === "zh" ? "无已拒绝记录" : "None" })])]),
]),
]);
const whatsappAlertBindingCard = el("div", { class: "card" }, [
el("div", { class: "card__title", text: currentLang === "zh" ? "WhatsApp 告警群绑定" : "WhatsApp alert group" }),
el("div", { class: "muted", text: currentLang === "zh" ? "NetX 关键告警将推送到此群;与 Chat 会话删除无关。" : "NetX key alerts go to this group; independent of chat sessions." }),
@ -1908,6 +2298,7 @@ async function renderStack() {
]),
weixinDispatchCard,
whatsappDispatchCard,
whatsappAccessCard,
whatsappAlertBindingCard,
el("div", { class: "card" }, [
el("div", { class: "card__title", text: currentLang === "zh" ? "提示词/工具预热" : "Prompt/Tool Prewarm" }),