mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:23:23 +08:00
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:
parent
8781a200c9
commit
1ea13a205d
13 changed files with 3048 additions and 30 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue