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" }),

View file

@ -307,8 +307,14 @@ def create_app() -> FastAPI:
return {"ok": False, "error": "missing id"}
ok = bool(body.get("ok", True))
err = str(body.get("error") or "").strip()
stanza_id = str(body.get("stanza_id") or "").strip()
store = get_assistant_store()
changed = store.ack_channel_outbound_message(message_id=msg_id, ok=ok, error=err)
changed = store.ack_channel_outbound_message(
message_id=msg_id,
ok=ok,
error=err,
stanza_id=stanza_id,
)
return {"ok": changed}
@app.get("/weixin/outbound/pending")

View file

@ -58,7 +58,7 @@ def _resolve_channel_dispatch(store: Any, *, channel: str, account: dict[str, An
_get_channel_dispatch_setting(store, _CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX, ch) or "generalist"
)
lang = _normalize_channel_dispatch_lang(
_get_channel_dispatch_setting(store, _CHANNEL_DISPATCH_LANG_KEY_PREFIX, ch) or "auto"
_get_channel_dispatch_setting(store, _CHANNEL_DISPATCH_LANG_KEY_PREFIX, ch) or ("en" if ch == "whatsapp" else "auto")
)
cfg = (account or {}).get("config")
if isinstance(cfg, dict):
@ -820,6 +820,17 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
text[:120],
)
return {"ok": True, "replies": []}
if str(inbound.channel or "").strip().lower() == "whatsapp":
from runtime.application.gateway.whatsapp_inbound_access import handle_whatsapp_access
access_out = handle_whatsapp_access(
store,
inbound=inbound,
account_id=account_id,
text=text,
)
if access_out is not None:
return access_out
reply = ""
reply_attachments: list[dict[str, Any]] = []
ident = store.resolve_user_by_channel_identity_v2(
@ -829,7 +840,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
)
if not ident:
owner = _ensure_administrator_owner(store)
if owner:
if owner and str(inbound.channel or "").strip().lower() != "whatsapp":
store.upsert_user_channel_account(
tenant_id=str(owner.get("tenant_id") or ""),
user_id=str(owner.get("user_id") or ""),

View file

@ -0,0 +1,512 @@
from __future__ import annotations
import json
import uuid
from typing import Any
from runtime.extensions.whatsapp.access_control import (
admin_approval_result_text,
admin_notify_text,
default_access_lang,
default_access_mode,
denied_reply_text,
extract_participant_alt,
extract_push_name,
extract_quote_context,
extract_remote_jid_alt,
is_access_allowed,
normalize_whatsapp_phone,
parse_admin_access_command,
parse_admin_approval_intent,
parse_pending_id_from_notify_text,
phone_from_jid,
resolve_sender_phone,
resolve_whatsapp_sender_jid,
whatsapp_sender_lookup_jids,
)
from runtime.extensions.whatsapp.api import is_whatsapp_user_target
from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id
def _extract_contact_fields(inbound: Any) -> tuple[str, str, str, str]:
meta = inbound.metadata if isinstance(inbound.metadata, dict) else {}
push_name = extract_push_name(meta)
raw_jid = str(inbound.external_user_id or "").strip()
participant_alt = extract_participant_alt(meta)
remote_jid_alt = extract_remote_jid_alt(meta)
canonical_jid = resolve_whatsapp_sender_jid(raw_jid, meta)
phone = resolve_sender_phone(raw_jid, participant_alt, remote_jid_alt)
return push_name, phone, raw_jid, canonical_jid
def _link_identity_aliases(
store: Any,
*,
tenant_id: str,
account_id: str,
user_id: str,
jids: tuple[str, ...],
) -> None:
for jid in jids:
val = str(jid or "").strip()
if not val:
continue
store.upsert_channel_identity_v2(
tenant_id=tenant_id,
channel="whatsapp",
account_id=account_id,
external_user_id=val,
user_id=user_id,
)
def _ensure_admin_identity(
store: Any,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
participant_alt: str,
push_name: str,
phone: str,
) -> dict[str, Any] | None:
lookup_jids = whatsapp_sender_lookup_jids(external_user_id, participant_alt)
for jid in lookup_jids:
ident = store.resolve_user_by_channel_identity_v2(
channel="whatsapp",
account_id=account_id,
external_user_id=jid,
)
if ident:
_link_identity_aliases(
store,
tenant_id=tenant_id,
account_id=account_id,
user_id=str(ident.get("user_id") or ""),
jids=lookup_jids,
)
return ident
admin_user = store.get_user_by_username(tenant_id=tenant_id, username="administrator")
user_id = str((admin_user or {}).get("id") or "")
if not user_id:
return _ensure_guest_identity(
store,
tenant_id=tenant_id,
account_id=account_id,
external_user_id=external_user_id,
participant_alt=participant_alt,
push_name=push_name,
phone=phone,
)
_link_identity_aliases(
store,
tenant_id=tenant_id,
account_id=account_id,
user_id=user_id,
jids=lookup_jids,
)
return store.resolve_user_by_channel_identity_v2(
channel="whatsapp",
account_id=account_id,
external_user_id=lookup_jids[0] if lookup_jids else external_user_id,
)
def _ensure_guest_identity(
store: Any,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
participant_alt: str,
push_name: str,
phone: str,
) -> dict[str, Any] | None:
lookup_jids = whatsapp_sender_lookup_jids(external_user_id, participant_alt)
for jid in lookup_jids:
ident = store.resolve_user_by_channel_identity_v2(
channel="whatsapp",
account_id=account_id,
external_user_id=jid,
)
if ident:
_link_identity_aliases(
store,
tenant_id=tenant_id,
account_id=account_id,
user_id=str(ident.get("user_id") or ""),
jids=lookup_jids,
)
return ident
label = str(push_name or "").strip() or phone or external_user_id
username = f"wa_{phone or uuid.uuid4().hex[:10]}"
user = store.create_user_account(
tenant_id=tenant_id,
username=username,
display_name=label,
role="guest",
password_hash="",
is_active=True,
)
user_id = str((user or {}).get("id") or "")
if not user_id:
return None
_link_identity_aliases(
store,
tenant_id=tenant_id,
account_id=account_id,
user_id=user_id,
jids=lookup_jids,
)
return store.resolve_user_by_channel_identity_v2(
channel="whatsapp",
account_id=account_id,
external_user_id=lookup_jids[0] if lookup_jids else external_user_id,
)
def _notify_admins(
store: Any,
*,
tenant_id: str,
account_id: str,
lang: str,
push_name: str,
external_user_id: str,
request_text: str,
pending_id: str,
) -> None:
admins = store.list_whatsapp_contacts(
tenant_id=tenant_id,
account_id=account_id,
list_type="admin",
)
text = admin_notify_text(
lang=lang,
push_name=push_name,
external_user_id=external_user_id,
request_text=request_text,
pending_id=pending_id,
)
for admin in admins:
chat_id = str(admin.get("external_user_id") or "").strip()
if not chat_id or not is_whatsapp_user_target(chat_id):
continue
store.enqueue_channel_outbound_message(
channel="whatsapp",
chat_id=chat_id,
text=text,
tenant_id=tenant_id,
account_id=account_id,
source=json.dumps(
{
"kind": "whatsapp_access_pending",
"pending_id": str(pending_id or ""),
},
ensure_ascii=False,
),
)
def _upsert_whatsapp_contact_profile(
store: Any,
*,
tenant_id: str,
account_id: str,
raw_jid: str,
canonical_jid: str,
push_name: str,
phone: str,
) -> None:
store.upsert_whatsapp_contact(
tenant_id=tenant_id,
account_id=account_id,
external_user_id=raw_jid,
push_name=push_name,
phone=phone,
)
if canonical_jid and canonical_jid != raw_jid:
store.upsert_whatsapp_contact(
tenant_id=tenant_id,
account_id=account_id,
external_user_id=canonical_jid,
push_name=push_name,
phone=phone_from_jid(canonical_jid),
)
def _handle_admin_message(
store: Any,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
text: str,
lang: str,
inbound: Any,
) -> dict[str, Any] | None:
cmd = parse_admin_access_command(text)
if cmd:
action = str(cmd.get("action") or "")
if action == "set_mode":
mode = str(cmd.get("access_mode") or default_access_mode())
store.upsert_whatsapp_access_config(
tenant_id=tenant_id,
account_id=account_id,
access_mode=mode,
lang=lang,
)
return {"text": f"Access mode set to {mode}.", "metadata": {}}
if action == "set_list":
list_type = str(cmd.get("list_type") or "")
target_raw = str(cmd.get("target") or "").strip()
try:
target_phone = normalize_whatsapp_phone(target_raw)
except Exception:
return {"text": f"Invalid target: {target_raw}", "metadata": {}}
store.apply_whatsapp_contact_access(
tenant_id=tenant_id,
account_id=account_id,
external_user_id=target_phone,
phone=target_phone,
list_type=list_type,
)
return {"text": f"Set {target_phone} as {list_type}.", "metadata": {}}
meta = inbound.metadata if isinstance(inbound.metadata, dict) else {}
quote = extract_quote_context(meta)
stanza_id = str(quote.get("stanza_id") or "").strip()
quoted_text = str(quote.get("quoted_text") or "").strip()
if not stanza_id and not quoted_text:
return None
admin_chat_id = str(getattr(inbound, "external_chat_id", None) or external_user_id or "").strip()
pending_id = ""
if stanza_id:
found = store.find_whatsapp_pending_by_notify_stanza(
tenant_id=tenant_id,
account_id=account_id,
admin_chat_id=admin_chat_id,
notify_stanza_id=stanza_id,
)
if found:
pending_id = str(found)
if not pending_id and quoted_text:
fallback = parse_pending_id_from_notify_text(quoted_text)
if fallback:
pending_id = fallback
if not pending_id:
return None
item = store.get_whatsapp_access_pending_by_id(pending_id=pending_id)
if not item or str(item.get("status") or "").strip().lower() != "pending":
return None
intent = parse_admin_approval_intent(text)
if not intent:
return None
target_jid = str(item.get("external_user_id") or "").strip()
target_name = str(item.get("push_name") or "").strip()
target_phone = str(item.get("phone") or "").strip() or resolve_sender_phone(target_jid)
approved = intent == "approve"
if approved:
store.apply_whatsapp_contact_access(
tenant_id=tenant_id,
account_id=account_id,
external_user_id=target_phone,
push_name=target_name,
phone=target_phone,
list_type="whitelist",
)
store.resolve_whatsapp_access_pending(
pending_id=pending_id,
status="approved",
resolved_by=external_user_id,
)
else:
if target_phone:
store.apply_whatsapp_contact_access(
tenant_id=tenant_id,
account_id=account_id,
external_user_id=target_phone,
push_name=target_name,
phone=target_phone,
list_type="blacklist",
)
store.resolve_whatsapp_access_pending(
pending_id=pending_id,
status="denied",
resolved_by=external_user_id,
)
deleter = getattr(store, "delete_whatsapp_pending_notify_for_pending", None)
if callable(deleter):
deleter(pending_id=pending_id)
raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
reply_meta: dict[str, Any] = {
"quote_remote_jid": admin_chat_id,
"quote_stanza_id": str(raw.get("id") or "").strip(),
"quote_participant": str(raw.get("participant") or external_user_id or "").strip(),
"quote_text": str(text or "").strip(),
}
return {
"text": admin_approval_result_text(
lang=lang,
approved=approved,
push_name=target_name,
external_user_id=target_jid,
),
"metadata": reply_meta,
}
def handle_whatsapp_access(
store: Any,
*,
inbound: Any,
account_id: str,
text: str,
) -> dict[str, Any] | None:
"""Return an inbound response dict when access gate short-circuits normal processing."""
tenant_id = resolve_whatsapp_tenant_id(store, account_id=account_id)
meta = inbound.metadata if isinstance(inbound.metadata, dict) else {}
participant_alt = extract_participant_alt(meta)
remote_jid_alt = extract_remote_jid_alt(meta)
push_name, phone, raw_jid, canonical_jid = _extract_contact_fields(inbound)
if not raw_jid:
return None
cfg = store.get_whatsapp_access_config(tenant_id=tenant_id, account_id=account_id)
access_mode = str((cfg or {}).get("access_mode") or default_access_mode())
lang = str((cfg or {}).get("lang") or default_access_lang())
matched = store.find_whatsapp_contact_for_sender(
tenant_id=tenant_id,
account_id=account_id,
external_user_id=raw_jid,
participant_alt=participant_alt,
remote_jid_alt=remote_jid_alt,
)
list_type = str((matched or {}).get("list_type") or "").strip().lower() or None
if list_type == "admin":
admin_reply = _handle_admin_message(
store,
tenant_id=tenant_id,
account_id=account_id,
external_user_id=raw_jid,
text=text,
lang=lang,
inbound=inbound,
)
if admin_reply:
return {
"ok": True,
"replies": [
{
"channel": "whatsapp",
"chat_id": inbound.external_chat_id,
"text": str(admin_reply.get("text") or ""),
"attachments": [],
"metadata": admin_reply.get("metadata")
if isinstance(admin_reply.get("metadata"), dict)
else {},
}
],
"whatsapp_access": "admin_command",
}
_upsert_whatsapp_contact_profile(
store,
tenant_id=tenant_id,
account_id=account_id,
raw_jid=raw_jid,
canonical_jid=canonical_jid,
push_name=push_name,
phone=phone,
)
_ensure_admin_identity(
store,
tenant_id=tenant_id,
account_id=account_id,
external_user_id=raw_jid,
participant_alt=participant_alt,
push_name=push_name,
phone=phone,
)
return None
if is_access_allowed(access_mode=access_mode, list_type=list_type):
_upsert_whatsapp_contact_profile(
store,
tenant_id=tenant_id,
account_id=account_id,
raw_jid=raw_jid,
canonical_jid=canonical_jid,
push_name=push_name,
phone=phone,
)
_ensure_guest_identity(
store,
tenant_id=tenant_id,
account_id=account_id,
external_user_id=raw_jid,
participant_alt=participant_alt,
push_name=push_name,
phone=phone,
)
return None
pending_id = store.create_whatsapp_access_pending(
tenant_id=tenant_id,
account_id=account_id,
external_user_id=raw_jid,
push_name=push_name,
phone=phone,
request_text=text,
)
if pending_id:
_notify_admins(
store,
tenant_id=tenant_id,
account_id=account_id,
lang=lang,
push_name=push_name,
external_user_id=raw_jid,
request_text=text,
pending_id=pending_id,
)
reply_meta: dict[str, Any] = {}
if bool(getattr(inbound, "is_group", False)):
raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
stanza_id = str(raw.get("id") or "").strip()
quote_participant = str(raw.get("participant") or raw_jid or "").strip()
reply_meta = {
"mention_jids": [raw_jid],
"quote_remote_jid": str(inbound.external_chat_id or "").strip(),
"quote_stanza_id": stanza_id,
"quote_participant": quote_participant,
"quote_text": str(text or "").strip(),
}
return {
"ok": True,
"replies": [
{
"channel": "whatsapp",
"chat_id": inbound.external_chat_id,
"text": denied_reply_text(lang=lang),
"attachments": [],
"metadata": reply_meta,
}
],
"whatsapp_access": "denied",
}
__all__ = ["handle_whatsapp_access"]

View file

@ -0,0 +1,392 @@
from __future__ import annotations
import re
from typing import Any, Literal
from runtime.extensions.whatsapp.api import normalize_whatsapp_target
AccessMode = Literal["blacklist", "whitelist"]
ListType = Literal["admin", "whitelist", "blacklist"]
ApprovalIntent = Literal["approve", "deny"]
_AFFIRMATIVE = frozenset(
{
"yes",
"y",
"approve",
"allow",
"add",
"ok",
"grant",
"whitelist",
"同意",
"可以",
"是",
"添加",
"通过",
}
)
_NEGATIVE = frozenset(
{
"no",
"n",
"deny",
"reject",
"ignore",
"拒绝",
"不",
"否",
"忽略",
}
)
def default_access_mode() -> AccessMode:
return "blacklist"
def default_access_lang() -> str:
return "en"
def is_access_allowed(*, access_mode: str, list_type: str | None) -> bool:
lt = str(list_type or "").strip().lower()
if lt == "admin":
return True
mode = str(access_mode or default_access_mode()).strip().lower()
if mode == "whitelist":
return lt != "blacklist"
return lt == "whitelist"
def extract_push_name(metadata: dict[str, Any] | None) -> str:
if not isinstance(metadata, dict):
return ""
for key in ("push_name", "display_name", "pushName"):
val = metadata.get(key)
if val is not None and str(val).strip():
return str(val).strip()
raw = metadata.get("raw")
if isinstance(raw, dict):
for key in ("pushName", "push_name", "display_name"):
val = raw.get(key)
if val is not None and str(val).strip():
return str(val).strip()
return ""
def is_whatsapp_lid_jid(jid: str) -> bool:
return str(jid or "").strip().lower().endswith("@lid")
def phone_from_jid(jid: str) -> str:
if is_whatsapp_lid_jid(jid):
return ""
base = str(jid or "").split("@", 1)[0].strip()
digits = re.sub(r"\D", "", base)
return digits or base
def normalize_whatsapp_phone(value: str) -> str:
raw = str(value or "").strip()
if not raw:
raise ValueError("phone is required")
digits = phone_from_jid(raw) if "@" in raw else re.sub(r"\D", "", raw.lstrip("+"))
if len(digits) < 6:
raise ValueError(f"invalid phone: {value}")
return digits
def resolve_sender_phone(
external_user_id: str,
participant_alt: str = "",
remote_jid_alt: str = "",
) -> str:
for alt in (str(participant_alt or "").strip(), str(remote_jid_alt or "").strip()):
if alt.lower().endswith("@s.whatsapp.net"):
alt_phone = phone_from_jid(alt)
if len(alt_phone) >= 6:
return alt_phone
canonical = resolve_whatsapp_sender_jid(
external_user_id,
{
"raw": {
"participantAlt": str(participant_alt or "").strip() or None,
"remoteJidAlt": str(remote_jid_alt or "").strip() or None,
}
},
)
if str(canonical or "").lower().endswith("@s.whatsapp.net"):
canonical_phone = phone_from_jid(canonical)
if len(canonical_phone) >= 6:
return canonical_phone
if not is_whatsapp_lid_jid(external_user_id):
raw_phone = phone_from_jid(external_user_id)
if len(raw_phone) >= 6:
return raw_phone
return ""
def contact_phone_key(row: dict[str, Any] | None) -> str:
if not isinstance(row, dict):
return ""
phone = str(row.get("phone") or "").strip()
if phone:
return phone
return phone_from_jid(str(row.get("external_user_id") or ""))
def whatsapp_phones_match(a: str, b: str) -> bool:
left = str(a or "").strip()
right = str(b or "").strip()
if not left or not right:
return False
try:
return normalize_whatsapp_phone(left) == normalize_whatsapp_phone(right)
except Exception:
left_phone = phone_from_jid(left)
right_phone = phone_from_jid(right)
return bool(len(left_phone) >= 6 and left_phone == right_phone)
def jid_local_part(jid: str) -> str:
return str(jid or "").split("@", 1)[0].split(":", 1)[0].strip().lower()
def extract_remote_jid_alt(metadata: dict[str, Any] | None) -> str:
if not isinstance(metadata, dict):
return ""
raw = metadata.get("raw")
if isinstance(raw, dict):
for key in ("remoteJidAlt", "remote_jid_alt"):
val = raw.get(key)
if val is not None and str(val).strip():
return str(val).strip()
return ""
def extract_participant_alt(metadata: dict[str, Any] | None) -> str:
if not isinstance(metadata, dict):
return ""
raw = metadata.get("raw")
if isinstance(raw, dict):
for key in ("participantAlt", "participant_alt"):
val = raw.get(key)
if val is not None and str(val).strip():
return str(val).strip()
return ""
def resolve_whatsapp_sender_jid(external_user_id: str, metadata: dict[str, Any] | None = None) -> str:
jid = str(external_user_id or "").strip()
participant_alt = extract_participant_alt(metadata)
remote_jid_alt = extract_remote_jid_alt(metadata)
low = jid.lower()
for alt in (participant_alt, remote_jid_alt):
if alt and low.endswith("@lid"):
try:
return normalize_whatsapp_target(alt)
except Exception:
pass
if low.endswith("@s.whatsapp.net"):
return low
if low.endswith("@lid"):
for alt in (participant_alt, remote_jid_alt):
if alt:
try:
return normalize_whatsapp_target(alt)
except Exception:
pass
return jid
try:
return normalize_whatsapp_target(jid)
except Exception:
return jid
def whatsapp_sender_lookup_jids(external_user_id: str, participant_alt: str = "") -> tuple[str, ...]:
out: list[str] = []
seen: set[str] = set()
canonical = resolve_whatsapp_sender_jid(
external_user_id,
{"raw": {"participantAlt": participant_alt}} if participant_alt else None,
)
for item in (external_user_id, participant_alt, canonical):
val = str(item or "").strip()
if val and val not in seen:
seen.add(val)
out.append(val)
low = str(external_user_id or "").lower()
if low.endswith("@lid"):
alias = f"{jid_local_part(external_user_id)}@s.whatsapp.net"
if alias not in seen:
seen.add(alias)
out.append(alias)
return tuple(out)
def whatsapp_users_match(a: str, b: str) -> bool:
left = str(a or "").strip()
right = str(b or "").strip()
if not left or not right:
return False
if whatsapp_phones_match(left, right):
return True
if left.lower() == right.lower():
return True
if jid_local_part(left) == jid_local_part(right):
return True
return False
def extract_quote_context(metadata: dict[str, Any] | None) -> dict[str, str]:
out = {
"stanza_id": "",
"quoted_text": "",
"remote_jid": "",
"participant": "",
}
if not isinstance(metadata, dict):
return out
raw = metadata.get("raw")
if not isinstance(raw, dict):
return out
out["stanza_id"] = str(
raw.get("quotedStanzaId") or raw.get("quoted_stanza_id") or ""
).strip()
out["quoted_text"] = str(
raw.get("quotedText") or raw.get("quoted_text") or ""
).strip()
out["participant"] = str(
raw.get("quotedParticipant")
or raw.get("quoted_participant")
or raw.get("participant")
or ""
).strip()
for key in ("quotedRemoteJid", "quoted_remote_jid", "remoteJid"):
val = raw.get(key)
if val is not None and str(val).strip():
out["remote_jid"] = str(val).strip()
break
return out
_PENDING_ID_IN_NOTIFY_RE = re.compile(
r"(?:请求编号|Request)\s*[::]\s*([0-9a-f]{16,64})",
re.IGNORECASE,
)
def parse_pending_id_from_notify_text(text: str) -> str | None:
m = _PENDING_ID_IN_NOTIFY_RE.search(str(text or ""))
if not m:
return None
return str(m.group(1) or "").strip() or None
def admin_approval_quote_required_text(*, lang: str) -> str:
if str(lang or "").strip().lower().startswith("zh"):
return "请引用待审批通知消息后回复「同意」或「拒绝」。"
return "Reply YES or NO by quoting the pending access notification."
def admin_approval_unknown_notify_text(*, lang: str) -> str:
if str(lang or "").strip().lower().startswith("zh"):
return "无法识别该审批请求,请引用正确的通知消息。"
return "Cannot identify this approval request. Please quote the correct notification message."
def parse_admin_approval_intent(text: str) -> ApprovalIntent | None:
blob = str(text or "").strip().lower()
if not blob:
return None
first = re.split(r"[\s,,。.!!??]+", blob, maxsplit=1)[0].strip()
if first in _AFFIRMATIVE or any(tok in blob for tok in ("add to whitelist", "grant access", "添加白名单", "加入白名单")):
return "approve"
if first in _NEGATIVE or any(tok in blob for tok in ("do not add", "don't add", "不要添加", "不加")):
return "deny"
return None
def parse_admin_access_command(text: str) -> dict[str, Any] | None:
raw = str(text or "").strip()
if not raw:
return None
low = raw.lower()
mode_match = re.match(r"^(?:access\s+mode|模式)\s+(blacklist|whitelist|黑名单|白名单)\b", low)
if mode_match:
token = mode_match.group(1)
if token in {"黑名单", "blacklist"}:
return {"action": "set_mode", "access_mode": "blacklist"}
return {"action": "set_mode", "access_mode": "whitelist"}
for verb, list_type in (
(r"^(?:whitelist|白名单)\s+(?:add\s+)?(.+)$", "whitelist"),
(r"^(?:blacklist|黑名单)\s+(?:add\s+)?(.+)$", "blacklist"),
(r"^(?:admin|管理员)\s+(?:add\s+)?(.+)$", "admin"),
):
m = re.match(verb, raw, flags=re.IGNORECASE)
if m:
target = str(m.group(1) or "").strip()
if target:
return {"action": "set_list", "list_type": list_type, "target": target}
return None
def normalize_contact_jid(value: str) -> str:
return normalize_whatsapp_target(str(value or "").strip())
def coerce_whatsapp_access_target(value: str) -> str:
"""Normalize manual contact input to canonical WhatsApp user JID from phone."""
return normalize_whatsapp_target(normalize_whatsapp_phone(value))
def denied_reply_text(*, lang: str) -> str:
if str(lang or "").strip().lower().startswith("zh"):
return "无权限:您尚未获得使用此助手的授权。请联系管理员。"
return "Access denied: you are not authorized to use this assistant. Please contact an administrator."
def admin_notify_text(
*,
lang: str,
push_name: str,
external_user_id: str,
request_text: str,
pending_id: str,
) -> str:
phone = phone_from_jid(external_user_id)
name = str(push_name or "").strip() or phone or external_user_id
preview = str(request_text or "").strip()
if len(preview) > 160:
preview = preview[:157] + "..."
if str(lang or "").strip().lower().startswith("zh"):
return (
f"[oclaw] 未授权用户请求访问\n"
f"用户: {name}\n"
f"ID: {external_user_id}\n"
f"消息: {preview or '(empty)'}\n"
f"请求编号: {pending_id}\n"
f"请引用本条消息回复「同意」或「拒绝」。"
)
return (
f"[oclaw] Unauthorized access request\n"
f"User: {name}\n"
f"ID: {external_user_id}\n"
f"Message: {preview or '(empty)'}\n"
f"Request: {pending_id}\n"
f"Reply YES or NO by quoting this message."
)
def admin_approval_result_text(*, lang: str, approved: bool, push_name: str, external_user_id: str) -> str:
name = str(push_name or "").strip() or phone_from_jid(external_user_id) or external_user_id
if str(lang or "").strip().lower().startswith("zh"):
if approved:
return f"已将 {name} ({external_user_id}) 加入白名单。"
return f"已忽略 {name} ({external_user_id}) 的访问请求。"
if approved:
return f"Whitelisted {name} ({external_user_id})."
return f"Ignored access request from {name} ({external_user_id})."

View file

@ -0,0 +1,14 @@
from __future__ import annotations
import os
from typing import Any
def resolve_whatsapp_tenant_id(store: Any, *, account_id: str) -> str:
account = store.find_user_by_channel_account(channel="whatsapp", account_id=str(account_id or "").strip())
if account and str(account.get("tenant_id") or "").strip():
return str(account["tenant_id"])
return str(os.getenv("OCLAW_DEFAULT_TENANT_ID") or "default").strip() or "default"
__all__ = ["resolve_whatsapp_tenant_id"]

View file

@ -162,6 +162,36 @@ function resolveSenderJid(key: proto.IMessageKey): string {
return "";
}
function resolvePnFromLid(sock: ReturnType<typeof makeWASocket> | null, lidJid: string): string {
const lid = String(lidJid || "").trim();
if (!lid || !sock) return "";
const lidMapping = (sock as any)?.signalRepository?.lidMapping;
if (!lidMapping || typeof lidMapping.getPNForLID !== "function") return "";
try {
const pn = lidMapping.getPNForLID(lid);
return pn ? jidNormalizedUser(String(pn)) : "";
} catch {
return "";
}
}
function resolveDmUserJid(key: proto.IMessageKey, sock: ReturnType<typeof makeWASocket> | null): string {
const remoteJid = jidNormalizedUser(String(key.remoteJid || ""));
const remoteJidAlt = String((key as any).remoteJidAlt || "").trim();
if (remoteJidAlt) {
const alt = jidNormalizedUser(remoteJidAlt);
if (remoteJid.toLowerCase().endsWith("@lid") && alt.toLowerCase().includes("@s.whatsapp")) {
return alt;
}
if (alt.toLowerCase().includes("@s.whatsapp")) return alt;
}
if (remoteJid.toLowerCase().endsWith("@lid")) {
const pn = resolvePnFromLid(sock, remoteJid);
if (pn) return pn;
}
return remoteJid;
}
function jidBaseLocal(jid: string): string {
const s = String(jid || "").trim().toLowerCase();
if (!s) return "";
@ -727,13 +757,22 @@ async function pollOutboundQueue(sock: ReturnType<typeof makeWASocket>): Promise
let ok = true;
let err = "";
try {
await sock.sendMessage(chatId, { text });
log(`outbound sent id=${id} chat=${chatId}`);
const sent = await sock.sendMessage(chatId, { text });
const stanzaId = String((sent as any)?.key?.id || "").trim();
log(`outbound sent id=${id} chat=${chatId} stanza=${stanzaId || "?"}`);
try {
await fetch(`${LOCAL_BASE_URL.replace(/\/+$/, "")}/whatsapp/outbound/ack`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, ok, error: err, stanza_id: stanzaId }),
});
} catch {
// best-effort ack
}
} catch (sendErr) {
ok = false;
err = String(sendErr);
log(`outbound send failed id=${id} chat=${chatId} err=${err.slice(0, 160)}`);
}
try {
await fetch(`${LOCAL_BASE_URL.replace(/\/+$/, "")}/whatsapp/outbound/ack`, {
method: "POST",
@ -744,6 +783,7 @@ async function pollOutboundQueue(sock: ReturnType<typeof makeWASocket>): Promise
// best-effort ack
}
}
}
} catch (err) {
if (VERBOSE) log(`outbound poll error: ${String(err)}`);
}
@ -933,10 +973,12 @@ async function main(): Promise<void> {
const isGroup = remoteJid.endsWith("@g.us");
const participantRaw = String(key.participant || "").trim();
const participantAlt = String((key as any).participantAlt || "").trim();
const remoteJidAlt = String((key as any).remoteJidAlt || "").trim();
const remoteJidNorm = jidNormalizedUser(remoteJid);
const userId = isGroup
? resolveSenderJid(key) || jidNormalizedUser(remoteJid)
: jidNormalizedUser(remoteJid);
const chatId = jidNormalizedUser(remoteJid);
? resolveSenderJid(key) || remoteJidNorm
: resolveDmUserJid(key, sock);
const chatId = remoteJidNorm;
const mentions = extractMentionsFromUpsert(msg);
const quote = extractQuoteContext(msg.message);
const botJidRaw = sock?.user?.id ? String(sock.user.id).trim() : "";
@ -960,6 +1002,7 @@ async function main(): Promise<void> {
const raw = {
id,
remoteJid,
remoteJidAlt: remoteJidAlt || null,
participant: participantRaw || null,
participantAlt: participantAlt || null,
pushName: (msg as any).pushName || null,

View file

@ -658,6 +658,72 @@ class SqliteStore(ScheduledJobStoreMixin):
);
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_access_config (
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
access_mode TEXT NOT NULL DEFAULT 'blacklist',
lang TEXT NOT NULL DEFAULT 'en',
updated_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, account_id)
);
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_contact (
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
external_user_id TEXT NOT NULL,
push_name TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
list_type TEXT,
notes TEXT NOT NULL DEFAULT '',
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, account_id, external_user_id)
);
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_access_pending (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
external_user_id TEXT NOT NULL,
push_name TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
request_text TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
resolved_at TEXT,
resolved_by TEXT NOT NULL DEFAULT ''
);
"""
)
self._ensure_whatsapp_access_pending_phone_column(conn)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_whatsapp_access_pending_status ON whatsapp_access_pending(tenant_id, account_id, status, created_at)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_access_pending_notify (
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
admin_chat_id TEXT NOT NULL,
notify_stanza_id TEXT NOT NULL,
pending_id TEXT NOT NULL,
notify_text TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, account_id, admin_chat_id, notify_stanza_id)
);
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_whatsapp_pending_notify_pending ON whatsapp_access_pending_notify(pending_id)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS channel_outbound_message (
@ -1248,6 +1314,72 @@ class SqliteStore(ScheduledJobStoreMixin):
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_access_config (
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
access_mode TEXT NOT NULL DEFAULT 'blacklist',
lang TEXT NOT NULL DEFAULT 'en',
updated_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, account_id)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_contact (
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
external_user_id TEXT NOT NULL,
push_name TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
list_type TEXT,
notes TEXT NOT NULL DEFAULT '',
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, account_id, external_user_id)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_access_pending (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
external_user_id TEXT NOT NULL,
push_name TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
request_text TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
resolved_at TEXT,
resolved_by TEXT NOT NULL DEFAULT ''
)
"""
)
self._ensure_whatsapp_access_pending_phone_column(conn)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_whatsapp_access_pending_status ON whatsapp_access_pending(tenant_id, account_id, status, created_at)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_access_pending_notify (
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
admin_chat_id TEXT NOT NULL,
notify_stanza_id TEXT NOT NULL,
pending_id TEXT NOT NULL,
notify_text TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, account_id, admin_chat_id, notify_stanza_id)
);
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_whatsapp_pending_notify_pending ON whatsapp_access_pending_notify(pending_id)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS channel_outbound_message (
@ -5583,6 +5715,621 @@ class SqliteStore(ScheduledJobStoreMixin):
out = self.get_whatsapp_alert_binding(tenant_id=tenant_id, account_id=account_id)
return out or {}
def get_whatsapp_access_config(
self,
*,
tenant_id: str,
account_id: str,
) -> dict[str, Any] | None:
with self._connect() as conn:
row = conn.execute(
"""
SELECT tenant_id, account_id, access_mode, lang, updated_at
FROM whatsapp_access_config
WHERE tenant_id = ? AND account_id = ?
""",
(str(tenant_id), str(account_id)),
).fetchone()
if not row:
return None
return {
"tenant_id": str(row["tenant_id"] or ""),
"account_id": str(row["account_id"] or ""),
"access_mode": str(row["access_mode"] or "blacklist"),
"lang": str(row["lang"] or "en"),
"updated_at": str(row["updated_at"] or ""),
}
def upsert_whatsapp_access_config(
self,
*,
tenant_id: str,
account_id: str,
access_mode: str,
lang: str = "en",
) -> dict[str, Any]:
ts = utc_now_iso()
mode = str(access_mode or "blacklist").strip().lower()
if mode not in {"blacklist", "whitelist"}:
mode = "blacklist"
lang_val = str(lang or "en").strip().lower() or "en"
with self._connect() as conn:
conn.execute(
"""
INSERT INTO whatsapp_access_config (tenant_id, account_id, access_mode, lang, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(tenant_id, account_id) DO UPDATE SET
access_mode = excluded.access_mode,
lang = excluded.lang,
updated_at = excluded.updated_at
""",
(str(tenant_id), str(account_id), mode, lang_val, ts),
)
return self.get_whatsapp_access_config(tenant_id=tenant_id, account_id=account_id) or {}
def upsert_whatsapp_contact(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
push_name: str = "",
phone: str = "",
list_type: str | None = None,
notes: str = "",
) -> dict[str, Any]:
ts = utc_now_iso()
lt = str(list_type).strip().lower() if list_type is not None else None
if lt is not None and lt not in {"admin", "whitelist", "blacklist"}:
lt = None
with self._connect() as conn:
conn.execute(
"""
INSERT INTO whatsapp_contact
(tenant_id, account_id, external_user_id, push_name, phone, list_type, notes, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tenant_id, account_id, external_user_id) DO UPDATE SET
push_name = CASE WHEN excluded.push_name != '' THEN excluded.push_name ELSE whatsapp_contact.push_name END,
phone = CASE WHEN excluded.phone != '' THEN excluded.phone ELSE whatsapp_contact.phone END,
list_type = COALESCE(excluded.list_type, whatsapp_contact.list_type),
notes = CASE WHEN excluded.notes != '' THEN excluded.notes ELSE whatsapp_contact.notes END,
last_seen_at = excluded.last_seen_at
""",
(
str(tenant_id),
str(account_id),
str(external_user_id),
str(push_name or ""),
str(phone or ""),
lt,
str(notes or ""),
ts,
ts,
),
)
return self.get_whatsapp_contact(
tenant_id=tenant_id,
account_id=account_id,
external_user_id=external_user_id,
) or {}
def get_whatsapp_contact(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
) -> dict[str, Any] | None:
with self._connect() as conn:
row = conn.execute(
"""
SELECT tenant_id, account_id, external_user_id, push_name, phone, list_type, notes, first_seen_at, last_seen_at
FROM whatsapp_contact
WHERE tenant_id = ? AND account_id = ? AND external_user_id = ?
""",
(str(tenant_id), str(account_id), str(external_user_id)),
).fetchone()
if not row:
return None
return {
"tenant_id": str(row["tenant_id"] or ""),
"account_id": str(row["account_id"] or ""),
"external_user_id": str(row["external_user_id"] or ""),
"push_name": str(row["push_name"] or ""),
"phone": str(row["phone"] or ""),
"list_type": str(row["list_type"] or "") or None,
"notes": str(row["notes"] or ""),
"first_seen_at": str(row["first_seen_at"] or ""),
"last_seen_at": str(row["last_seen_at"] or ""),
}
def list_whatsapp_contacts(
self,
*,
tenant_id: str,
account_id: str,
list_type: str | None = None,
limit: int = 200,
) -> list[dict[str, Any]]:
lim = max(1, min(int(limit), 500))
clauses = ["tenant_id = ?", "account_id = ?"]
params: list[Any] = [str(tenant_id), str(account_id)]
if list_type:
clauses.append("list_type = ?")
params.append(str(list_type))
where = " AND ".join(clauses)
with self._connect() as conn:
rows = conn.execute(
f"""
SELECT tenant_id, account_id, external_user_id, push_name, phone, list_type, notes, first_seen_at, last_seen_at
FROM whatsapp_contact
WHERE {where}
ORDER BY last_seen_at DESC
LIMIT ?
""",
(*params, lim),
).fetchall()
return [
{
"tenant_id": str(r["tenant_id"] or ""),
"account_id": str(r["account_id"] or ""),
"external_user_id": str(r["external_user_id"] or ""),
"push_name": str(r["push_name"] or ""),
"phone": str(r["phone"] or ""),
"list_type": str(r["list_type"] or "") or None,
"notes": str(r["notes"] or ""),
"first_seen_at": str(r["first_seen_at"] or ""),
"last_seen_at": str(r["last_seen_at"] or ""),
}
for r in rows
]
def delete_whatsapp_contact(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
) -> bool:
with self._connect() as conn:
cur = conn.execute(
"""
DELETE FROM whatsapp_contact
WHERE tenant_id = ? AND account_id = ? AND external_user_id = ?
""",
(str(tenant_id), str(account_id), str(external_user_id)),
)
return bool(cur.rowcount and cur.rowcount > 0)
def delete_whatsapp_contact_aliases(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
extra_tenant_ids: list[str] | None = None,
) -> int:
from runtime.extensions.whatsapp.access_control import (
contact_phone_key,
normalize_whatsapp_phone,
phone_from_jid,
whatsapp_users_match,
)
tenant_ids: list[str] = []
for tid in [str(tenant_id), *(extra_tenant_ids or [])]:
val = str(tid or "").strip()
if val and val not in tenant_ids:
tenant_ids.append(val)
try:
phone_val = normalize_whatsapp_phone(external_user_id)
except Exception:
phone_val = phone_from_jid(external_user_id)
contacts: list[dict[str, Any]] = []
for tid in tenant_ids:
contacts.extend(self.list_whatsapp_contacts(tenant_id=tid, account_id=account_id, limit=500))
targets: set[str] = set()
for row in contacts:
cj = str(row.get("external_user_id") or "").strip()
if not cj:
continue
if whatsapp_users_match(cj, external_user_id):
targets.add(cj)
elif phone_val and contact_phone_key(row) == phone_val:
targets.add(cj)
deleted_count = 0
with self._connect() as conn:
for tid in tenant_ids:
for jid in targets:
if not jid:
continue
cur = conn.execute(
"""
DELETE FROM whatsapp_contact
WHERE tenant_id = ? AND account_id = ? AND external_user_id = ?
""",
(str(tid), str(account_id), str(jid)),
)
if cur.rowcount:
deleted_count += int(cur.rowcount)
return deleted_count
def _ensure_whatsapp_access_pending_phone_column(self, conn: Any) -> None:
if self._use_pg:
conn.execute(
"ALTER TABLE whatsapp_access_pending ADD COLUMN IF NOT EXISTS phone TEXT NOT NULL DEFAULT ''"
)
return
cols = {row[1] for row in conn.execute("PRAGMA table_info(whatsapp_access_pending)").fetchall()}
if "phone" not in cols:
conn.execute("ALTER TABLE whatsapp_access_pending ADD COLUMN phone TEXT NOT NULL DEFAULT ''")
def create_whatsapp_access_pending(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
push_name: str = "",
phone: str = "",
request_text: str = "",
) -> str | None:
import uuid
from runtime.extensions.whatsapp.access_control import contact_phone_key, whatsapp_users_match
pending = self.list_whatsapp_access_pending(
tenant_id=tenant_id,
account_id=account_id,
status="pending",
limit=200,
)
phone_val = str(phone or "").strip()
for row in pending:
if whatsapp_users_match(str(row.get("external_user_id") or ""), external_user_id):
return str(row.get("id") or "")
if phone_val and contact_phone_key(row) == phone_val:
return str(row.get("id") or "")
pending_id = uuid.uuid4().hex
ts = utc_now_iso()
with self._connect() as conn:
conn.execute(
"""
INSERT INTO whatsapp_access_pending
(id, tenant_id, account_id, external_user_id, push_name, phone, request_text, status, created_at, resolved_at, resolved_by)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, NULL, '')
""",
(
pending_id,
str(tenant_id),
str(account_id),
str(external_user_id),
str(push_name or ""),
str(phone or ""),
str(request_text or ""),
ts,
),
)
return pending_id
def get_whatsapp_access_pending_by_id(self, *, pending_id: str) -> dict[str, Any] | None:
pid = str(pending_id or "").strip()
if not pid:
return None
with self._connect() as conn:
row = conn.execute(
"""
SELECT id, tenant_id, account_id, external_user_id, push_name, phone, request_text, status, created_at, resolved_at, resolved_by
FROM whatsapp_access_pending
WHERE id = ?
""",
(pid,),
).fetchone()
if not row:
return None
from runtime.extensions.whatsapp.access_control import phone_from_jid
return {
"id": str(row["id"] or ""),
"tenant_id": str(row["tenant_id"] or ""),
"account_id": str(row["account_id"] or ""),
"external_user_id": str(row["external_user_id"] or ""),
"push_name": str(row["push_name"] or ""),
"phone": str(row["phone"] or "") or phone_from_jid(str(row["external_user_id"] or "")),
"request_text": str(row["request_text"] or ""),
"status": str(row["status"] or ""),
"created_at": str(row["created_at"] or ""),
"resolved_at": str(row["resolved_at"] or "") or None,
"resolved_by": str(row["resolved_by"] or ""),
}
def upsert_whatsapp_pending_notify(
self,
*,
tenant_id: str,
account_id: str,
admin_chat_id: str,
notify_stanza_id: str,
pending_id: str,
notify_text: str = "",
) -> None:
ts = utc_now_iso()
with self._connect() as conn:
conn.execute(
"""
INSERT INTO whatsapp_access_pending_notify
(tenant_id, account_id, admin_chat_id, notify_stanza_id, pending_id, notify_text, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tenant_id, account_id, admin_chat_id, notify_stanza_id) DO UPDATE SET
pending_id = excluded.pending_id,
notify_text = CASE WHEN excluded.notify_text != '' THEN excluded.notify_text ELSE whatsapp_access_pending_notify.notify_text END,
created_at = excluded.created_at
""",
(
str(tenant_id),
str(account_id),
str(admin_chat_id),
str(notify_stanza_id),
str(pending_id),
str(notify_text or ""),
ts,
),
)
def find_whatsapp_pending_by_notify_stanza(
self,
*,
tenant_id: str,
account_id: str,
admin_chat_id: str,
notify_stanza_id: str,
) -> str | None:
stanza = str(notify_stanza_id or "").strip()
chat = str(admin_chat_id or "").strip()
if not stanza or not chat:
return None
with self._connect() as conn:
row = conn.execute(
"""
SELECT pending_id
FROM whatsapp_access_pending_notify
WHERE tenant_id = ? AND account_id = ? AND admin_chat_id = ? AND notify_stanza_id = ?
""",
(str(tenant_id), str(account_id), chat, stanza),
).fetchone()
return str(row["pending_id"] or "").strip() if row else None
def delete_whatsapp_pending_notify_for_pending(self, *, pending_id: str) -> int:
pid = str(pending_id or "").strip()
if not pid:
return 0
with self._connect() as conn:
cur = conn.execute(
"DELETE FROM whatsapp_access_pending_notify WHERE pending_id = ?",
(pid,),
)
return int(cur.rowcount or 0)
def list_whatsapp_access_pending(
self,
*,
tenant_id: str,
account_id: str,
status: str = "pending",
limit: int = 50,
) -> list[dict[str, Any]]:
lim = max(1, min(int(limit), 200))
from runtime.extensions.whatsapp.access_control import phone_from_jid
with self._connect() as conn:
rows = conn.execute(
"""
SELECT id, tenant_id, account_id, external_user_id, push_name, phone, request_text, status, created_at, resolved_at, resolved_by
FROM whatsapp_access_pending
WHERE tenant_id = ? AND account_id = ? AND status = ?
ORDER BY created_at ASC
LIMIT ?
""",
(str(tenant_id), str(account_id), str(status), lim),
).fetchall()
return [
{
"id": str(r["id"] or ""),
"tenant_id": str(r["tenant_id"] or ""),
"account_id": str(r["account_id"] or ""),
"external_user_id": str(r["external_user_id"] or ""),
"push_name": str(r["push_name"] or ""),
"phone": str(r["phone"] or "") or phone_from_jid(str(r["external_user_id"] or "")),
"request_text": str(r["request_text"] or ""),
"status": str(r["status"] or ""),
"created_at": str(r["created_at"] or ""),
"resolved_at": str(r["resolved_at"] or "") or None,
"resolved_by": str(r["resolved_by"] or ""),
}
for r in rows
]
def resolve_whatsapp_access_pending(
self,
*,
pending_id: str,
status: str,
resolved_by: str = "",
from_statuses: tuple[str, ...] = ("pending",),
) -> bool:
ts = utc_now_iso()
st = str(status or "").strip().lower()
if st not in {"approved", "denied", "dismissed"}:
return False
allowed = tuple(str(x or "").strip().lower() for x in from_statuses if str(x or "").strip())
if not allowed:
allowed = ("pending",)
placeholders = ", ".join("?" for _ in allowed)
with self._connect() as conn:
cur = conn.execute(
f"""
UPDATE whatsapp_access_pending
SET status = ?, resolved_at = ?, resolved_by = ?
WHERE id = ? AND status IN ({placeholders})
""",
(st, ts, str(resolved_by or ""), str(pending_id), *allowed),
)
return bool(cur.rowcount and cur.rowcount > 0)
def delete_whatsapp_access_pending(
self, *, pending_id: str, statuses: tuple[str, ...] = ("pending",)
) -> bool:
allowed = tuple(str(x or "").strip().lower() for x in statuses if str(x or "").strip())
if not allowed:
allowed = ("pending",)
placeholders = ", ".join("?" for _ in allowed)
with self._connect() as conn:
cur = conn.execute(
f"DELETE FROM whatsapp_access_pending WHERE id = ? AND status IN ({placeholders})",
(str(pending_id), *allowed),
)
return bool(cur.rowcount and cur.rowcount > 0)
def find_whatsapp_contact_for_sender(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
participant_alt: str = "",
remote_jid_alt: str = "",
) -> dict[str, Any] | None:
from runtime.extensions.whatsapp.access_control import (
contact_phone_key,
resolve_sender_phone,
whatsapp_users_match,
)
sender_phone = resolve_sender_phone(external_user_id, participant_alt, remote_jid_alt)
contacts = self.list_whatsapp_contacts(tenant_id=tenant_id, account_id=account_id, limit=500)
classified = [row for row in contacts if str(row.get("list_type") or "").strip()]
if sender_phone:
for row in classified:
if contact_phone_key(row) == sender_phone:
return row
for row in classified:
cj = str(row.get("external_user_id") or "")
if whatsapp_users_match(cj, external_user_id) or (
participant_alt and whatsapp_users_match(cj, participant_alt)
):
return row
return None
def apply_whatsapp_contact_access(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
list_type: str,
push_name: str = "",
phone: str = "",
participant_alt: str = "",
notes: str = "",
) -> dict[str, Any]:
from runtime.extensions.whatsapp.access_control import (
contact_phone_key,
normalize_whatsapp_phone,
)
from runtime.extensions.whatsapp.api import normalize_whatsapp_target
phone_val = ""
if str(phone or "").strip():
phone_val = normalize_whatsapp_phone(phone)
elif str(external_user_id or "").strip():
phone_val = normalize_whatsapp_phone(external_user_id)
else:
return {}
canonical = normalize_whatsapp_target(phone_val)
primary = self.upsert_whatsapp_contact(
tenant_id=tenant_id,
account_id=account_id,
external_user_id=canonical,
push_name=push_name,
phone=phone_val,
list_type=list_type,
notes=notes,
)
ts = utc_now_iso()
with self._connect() as conn:
for row in self.list_whatsapp_contacts(tenant_id=tenant_id, account_id=account_id, limit=500):
cj = str(row.get("external_user_id") or "")
if cj == canonical:
continue
if contact_phone_key(row) == phone_val:
conn.execute(
"""
UPDATE whatsapp_contact
SET list_type = ?, phone = ?, last_seen_at = ?
WHERE tenant_id = ? AND account_id = ? AND external_user_id = ?
""",
(str(list_type), phone_val, ts, str(tenant_id), str(account_id), cj),
)
return primary or {}
def resolve_whatsapp_pending_for_sender(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
participant_alt: str = "",
remote_jid_alt: str = "",
resolved_by: str = "",
status: str = "approved",
extra_tenant_ids: list[str] | None = None,
) -> int:
from runtime.extensions.whatsapp.access_control import (
contact_phone_key,
resolve_sender_phone,
whatsapp_users_match,
)
tenant_ids: list[str] = []
for tid in [str(tenant_id), *(extra_tenant_ids or [])]:
val = str(tid or "").strip()
if val and val not in tenant_ids:
tenant_ids.append(val)
sender_phone = resolve_sender_phone(external_user_id, participant_alt, remote_jid_alt)
resolved = 0
seen_ids: set[str] = set()
for tid in tenant_ids:
pending = self.list_whatsapp_access_pending(
tenant_id=tid,
account_id=account_id,
status="pending",
limit=200,
)
for item in pending:
pending_id = str(item.get("id") or "")
if not pending_id or pending_id in seen_ids:
continue
pending_jid = str(item.get("external_user_id") or "")
pending_phone = contact_phone_key(item)
if (sender_phone and pending_phone == sender_phone) or whatsapp_users_match(
pending_jid, external_user_id
) or (participant_alt and whatsapp_users_match(pending_jid, participant_alt)):
if self.resolve_whatsapp_access_pending(
pending_id=pending_id,
status=status,
resolved_by=resolved_by,
):
seen_ids.add(pending_id)
resolved += 1
return resolved
def enqueue_channel_outbound_message(
self,
*,
@ -5697,16 +6444,52 @@ class SqliteStore(ScheduledJobStoreMixin):
message_id: str,
ok: bool,
error: str = "",
stanza_id: str = "",
) -> bool:
ts = utc_now_iso()
status = "sent" if ok else "failed"
mid = str(message_id or "").strip()
if not mid:
return False
with self._connect() as conn:
row = conn.execute(
"""
SELECT tenant_id, account_id, chat_id, text, source
FROM channel_outbound_message
WHERE id = ? AND status = 'pending'
""",
(mid,),
).fetchone()
if not row:
return False
cur = conn.execute(
"""
UPDATE channel_outbound_message
SET status = ?, sent_at = ?, error = ?
WHERE id = ? AND status = 'pending'
""",
(status, ts, str(error or ""), str(message_id)),
(status, ts, str(error or ""), mid),
)
return bool(cur.rowcount and cur.rowcount > 0)
changed = bool(cur.rowcount and cur.rowcount > 0)
if changed and ok and str(stanza_id or "").strip():
source_raw = str(row["source"] or "").strip()
meta: dict[str, Any] = {}
if source_raw.startswith("{"):
try:
parsed = json.loads(source_raw)
if isinstance(parsed, dict):
meta = parsed
except Exception:
meta = {}
if str(meta.get("kind") or "") == "whatsapp_access_pending":
pending_id = str(meta.get("pending_id") or "").strip()
if pending_id:
self.upsert_whatsapp_pending_notify(
tenant_id=str(row["tenant_id"] or ""),
account_id=str(row["account_id"] or ""),
admin_chat_id=str(row["chat_id"] or ""),
notify_stanza_id=str(stanza_id or "").strip(),
pending_id=pending_id,
notify_text=str(row["text"] or ""),
)
return changed

View file

@ -0,0 +1,145 @@
from __future__ import annotations
from runtime.extensions.whatsapp.access_control import (
admin_notify_text,
extract_quote_context,
is_access_allowed,
normalize_whatsapp_phone,
parse_admin_access_command,
parse_admin_approval_intent,
parse_pending_id_from_notify_text,
resolve_sender_phone,
resolve_whatsapp_sender_jid,
whatsapp_phones_match,
whatsapp_users_match,
)
def test_blacklist_mode_denies_by_default() -> None:
assert is_access_allowed(access_mode="blacklist", list_type=None) is False
assert is_access_allowed(access_mode="blacklist", list_type="whitelist") is True
assert is_access_allowed(access_mode="blacklist", list_type="blacklist") is False
assert is_access_allowed(access_mode="blacklist", list_type="admin") is True
def test_whitelist_mode_allows_by_default() -> None:
assert is_access_allowed(access_mode="whitelist", list_type=None) is True
assert is_access_allowed(access_mode="whitelist", list_type="blacklist") is False
assert is_access_allowed(access_mode="whitelist", list_type="whitelist") is True
def test_admin_approval_intent() -> None:
assert parse_admin_approval_intent("yes") == "approve"
assert parse_admin_approval_intent("NO") == "deny"
assert parse_admin_approval_intent("add to whitelist") == "approve"
assert parse_admin_approval_intent("hello") is None
def test_whatsapp_users_match_lid_alias() -> None:
assert whatsapp_users_match("91010910658657@lid", "91010910658657@s.whatsapp.net") is True
def test_resolve_whatsapp_sender_jid_uses_participant_alt() -> None:
jid = resolve_whatsapp_sender_jid(
"91010910658657@lid",
{"raw": {"participantAlt": "8618142387786@s.whatsapp.net"}},
)
assert jid == "8618142387786@s.whatsapp.net"
def test_normalize_whatsapp_phone() -> None:
assert normalize_whatsapp_phone("+8615601877957") == "8615601877957"
assert normalize_whatsapp_phone("8615601877957@s.whatsapp.net") == "8615601877957"
def test_resolve_sender_phone_prefers_participant_alt() -> None:
phone = resolve_sender_phone(
"91010910658657@lid",
"8618142387786@s.whatsapp.net",
)
assert phone == "8618142387786"
def test_resolve_sender_phone_uses_remote_jid_alt_for_dm() -> None:
phone = resolve_sender_phone(
"91010910658657@lid",
"",
"8615601877957@s.whatsapp.net",
)
assert phone == "8615601877957"
def test_phone_from_jid_ignores_lid() -> None:
from runtime.extensions.whatsapp.access_control import phone_from_jid
assert phone_from_jid("91010910658657@lid") == ""
def test_resolve_whatsapp_sender_jid_uses_remote_jid_alt() -> None:
jid = resolve_whatsapp_sender_jid(
"91010910658657@lid",
{"raw": {"remoteJidAlt": "8615601877957@s.whatsapp.net"}},
)
assert jid == "8615601877957@s.whatsapp.net"
def test_whatsapp_phones_match_across_formats() -> None:
assert whatsapp_phones_match("+8615601877957", "8615601877957@s.whatsapp.net") is True
def test_coerce_whatsapp_access_target_normalizes_phone() -> None:
from runtime.extensions.whatsapp.access_control import coerce_whatsapp_access_target
assert coerce_whatsapp_access_target("+8618142387786") == "8618142387786@s.whatsapp.net"
assert coerce_whatsapp_access_target("8615601877957") == "8615601877957@s.whatsapp.net"
def test_admin_access_commands() -> None:
cmd = parse_admin_access_command("whitelist add +8615601877957")
assert cmd and cmd.get("action") == "set_list" and cmd.get("list_type") == "whitelist"
mode = parse_admin_access_command("access mode blacklist")
assert mode and mode.get("access_mode") == "blacklist"
def test_parse_pending_id_from_notify_text() -> None:
zh = "[oclaw] 未授权用户请求访问\n请求编号: abcdef0123456789\n"
en = "[oclaw] Unauthorized access request\nRequest: fedcba9876543210\n"
assert parse_pending_id_from_notify_text(zh) == "abcdef0123456789"
assert parse_pending_id_from_notify_text(en) == "fedcba9876543210"
assert parse_pending_id_from_notify_text("no id here") is None
def test_extract_quote_context() -> None:
ctx = extract_quote_context(
{
"raw": {
"quotedStanzaId": "stanza_notify_1",
"quotedText": "Request: abc123",
"participant": "111@s.whatsapp.net",
"remoteJid": "222@s.whatsapp.net",
}
}
)
assert ctx["stanza_id"] == "stanza_notify_1"
assert ctx["quoted_text"] == "Request: abc123"
assert ctx["participant"] == "111@s.whatsapp.net"
assert ctx["remote_jid"] == "222@s.whatsapp.net"
def test_admin_notify_text_prompts_quote_reply() -> None:
zh = admin_notify_text(
lang="zh",
push_name="Bob",
external_user_id="8615601877957@s.whatsapp.net",
request_text="hi",
pending_id="pending123",
)
en = admin_notify_text(
lang="en",
push_name="Bob",
external_user_id="8615601877957@s.whatsapp.net",
request_text="hi",
pending_id="pending123",
)
assert "请引用本条消息" in zh
assert "quoting this message" in en

View file

@ -0,0 +1,389 @@
from __future__ import annotations
from typing import Any
from runtime.application.gateway.whatsapp_inbound_access import handle_whatsapp_access
class _AccessStore:
def __init__(self) -> None:
self.contacts: dict[str, dict[str, Any]] = {}
self.pending: list[dict[str, Any]] = []
self.outbound: list[dict[str, Any]] = []
self.config: dict[str, Any] = {"access_mode": "blacklist", "lang": "en"}
self.identities: dict[str, dict[str, Any]] = {}
self.users: list[dict[str, Any]] = []
self.notify_map: dict[tuple[str, str], str] = {}
self._pending_seq = 0
def get_whatsapp_access_config(self, *, tenant_id: str, account_id: str) -> dict[str, Any]:
return dict(self.config)
def upsert_whatsapp_access_config(self, **kwargs: Any) -> dict[str, Any]:
self.config.update({k: v for k, v in kwargs.items() if k in {"access_mode", "lang"}})
return dict(self.config)
def apply_whatsapp_contact_access(self, **kwargs: Any) -> dict[str, Any]:
return self.upsert_whatsapp_contact(**kwargs)
def find_whatsapp_contact_for_sender(self, **kwargs: Any) -> dict[str, Any] | None:
from runtime.extensions.whatsapp.access_control import (
contact_phone_key,
resolve_sender_phone,
whatsapp_users_match,
)
jid = str(kwargs.get("external_user_id") or "")
alt = str(kwargs.get("participant_alt") or "")
remote_alt = str(kwargs.get("remote_jid_alt") or "")
sender_phone = resolve_sender_phone(jid, alt, remote_alt)
for row in self.contacts.values():
if not row.get("list_type"):
continue
if sender_phone and contact_phone_key(row) == sender_phone:
return row
cj = str(row.get("external_user_id") or "")
if whatsapp_users_match(cj, jid) or (alt and whatsapp_users_match(cj, alt)):
return row
return None
def resolve_whatsapp_pending_for_sender(self, **kwargs: Any) -> int:
return 0
def upsert_whatsapp_contact(self, **kwargs: Any) -> dict[str, Any]:
jid = str(kwargs.get("external_user_id") or "")
row = dict(self.contacts.get(jid) or {})
row.update({k: v for k, v in kwargs.items() if v is not None})
if kwargs.get("list_type") is None and jid in self.contacts:
row["list_type"] = self.contacts[jid].get("list_type")
self.contacts[jid] = row
return row
def list_whatsapp_contacts(self, *, tenant_id: str, account_id: str, list_type: str | None = None, limit: int = 200) -> list[dict[str, Any]]:
rows = list(self.contacts.values())
if list_type:
rows = [r for r in rows if str(r.get("list_type") or "") == list_type]
return rows
def create_whatsapp_access_pending(self, **kwargs: Any) -> str:
self._pending_seq += 1
pending_id = f"pending{self._pending_seq}"
for row in self.pending:
if row.get("status") == "pending" and row.get("external_user_id") == kwargs.get("external_user_id"):
return str(row.get("id") or pending_id)
self.pending.append(
{
"id": pending_id,
"external_user_id": kwargs.get("external_user_id"),
"push_name": kwargs.get("push_name"),
"phone": kwargs.get("phone"),
"request_text": kwargs.get("request_text"),
"status": "pending",
}
)
return pending_id
def list_whatsapp_access_pending(self, **kwargs: Any) -> list[dict[str, Any]]:
return [r for r in self.pending if r.get("status") == kwargs.get("status", "pending")]
def get_whatsapp_access_pending_by_id(self, *, pending_id: str) -> dict[str, Any] | None:
for row in self.pending:
if str(row.get("id") or "") == str(pending_id or ""):
return dict(row)
return None
def find_whatsapp_pending_by_notify_stanza(
self,
*,
tenant_id: str,
account_id: str,
admin_chat_id: str,
notify_stanza_id: str,
) -> str | None:
return self.notify_map.get((str(admin_chat_id), str(notify_stanza_id)))
def delete_whatsapp_pending_notify_for_pending(self, *, pending_id: str) -> int:
removed = 0
for key, pid in list(self.notify_map.items()):
if pid == pending_id:
del self.notify_map[key]
removed += 1
return removed
def resolve_whatsapp_access_pending(self, **kwargs: Any) -> bool:
for row in self.pending:
if row.get("id") == kwargs.get("pending_id"):
row["status"] = kwargs.get("status")
return True
return False
def enqueue_channel_outbound_message(self, **kwargs: Any) -> str:
self.outbound.append(dict(kwargs))
return "out1"
def resolve_user_by_channel_identity_v2(self, **kwargs: Any) -> dict[str, Any] | None:
return self.identities.get(str(kwargs.get("external_user_id") or ""))
def create_user_account(self, **kwargs: Any) -> dict[str, Any]:
user = {"id": "guest1", "role": kwargs.get("role", "guest")}
self.users.append(user)
return user
def upsert_channel_identity_v2(self, **kwargs: Any) -> None:
jid = str(kwargs.get("external_user_id") or "")
self.identities[jid] = {
"tenant_id": kwargs.get("tenant_id"),
"user_id": kwargs.get("user_id"),
"role": "guest",
}
def get_user_by_username(self, **kwargs: Any) -> dict[str, Any] | None:
return {"id": "admin1", "role": "owner"}
class _Inbound:
channel = "whatsapp"
external_user_id = "8615601877957@s.whatsapp.net"
external_chat_id = "8615601877957@s.whatsapp.net"
metadata = {"raw": {"pushName": "Bob"}}
class _InboundGroup:
channel = "whatsapp"
is_group = True
external_user_id = "91010910658657@lid"
external_chat_id = "999@g.us"
metadata = {
"raw": {
"pushName": "oliver",
"id": "stanza_1",
"participant": "91010910658657@lid",
"participantAlt": "8618142387786@s.whatsapp.net",
}
}
class _InboundAdmin:
channel = "whatsapp"
external_user_id = "111@s.whatsapp.net"
external_chat_id = "111@s.whatsapp.net"
metadata: dict[str, Any] = {"raw": {}}
def _setup_admin_store() -> _AccessStore:
store = _AccessStore()
store.contacts["111@s.whatsapp.net"] = {
"external_user_id": "111@s.whatsapp.net",
"phone": "111",
"list_type": "admin",
}
return store
def test_handle_whatsapp_access_admin_yes_without_quote_passes_to_llm(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _setup_admin_store()
store.pending.append(
{
"id": "pending1",
"external_user_id": "8615601877957@s.whatsapp.net",
"push_name": "Bob",
"phone": "8615601877957",
"status": "pending",
}
)
out = handle_whatsapp_access(store, inbound=_InboundAdmin(), account_id="wa-default", text="yes")
assert out is None
assert store.pending[0]["status"] == "pending"
def test_handle_whatsapp_access_admin_quoted_notify_without_intent_passes_to_llm(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _setup_admin_store()
store.pending.append(
{
"id": "pending2",
"external_user_id": "8615601877957@s.whatsapp.net",
"push_name": "Bob",
"phone": "8615601877957",
"status": "pending",
}
)
store.notify_map[("111@s.whatsapp.net", "notify_stanza_2")] = "pending2"
inbound = type("_InboundAdminQuoteHello", (), {
"channel": "whatsapp",
"external_user_id": "111@s.whatsapp.net",
"external_chat_id": "111@s.whatsapp.net",
"metadata": {
"raw": {
"quotedStanzaId": "notify_stanza_2",
"quotedText": "[oclaw] Unauthorized access request",
}
},
})()
out = handle_whatsapp_access(store, inbound=inbound, account_id="wa-default", text="hello")
assert out is None
assert store.pending[0]["status"] == "pending"
def test_handle_whatsapp_access_admin_yes_with_stanza_mapping(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _setup_admin_store()
store.pending.append(
{
"id": "pending2",
"external_user_id": "8615601877957@s.whatsapp.net",
"push_name": "Bob",
"phone": "8615601877957",
"status": "pending",
}
)
store.notify_map[("111@s.whatsapp.net", "notify_stanza_2")] = "pending2"
inbound = type("_InboundAdminQuote", (), {
"channel": "whatsapp",
"external_user_id": "111@s.whatsapp.net",
"external_chat_id": "111@s.whatsapp.net",
"metadata": {
"raw": {
"id": "admin_reply_1",
"participant": "111@s.whatsapp.net",
"quotedStanzaId": "notify_stanza_2",
"quotedText": "[oclaw] Unauthorized access request",
}
},
})()
out = handle_whatsapp_access(store, inbound=inbound, account_id="wa-default", text="yes")
assert out is not None
assert store.pending[0]["status"] == "approved"
assert store.contacts.get("8615601877957", {}).get("list_type") == "whitelist"
replies = out.get("replies") if isinstance(out.get("replies"), list) else []
md = replies[0].get("metadata") if replies and isinstance(replies[0], dict) else {}
assert md.get("quote_stanza_id") == "admin_reply_1"
def test_handle_whatsapp_access_admin_yes_with_quoted_text_fallback(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _setup_admin_store()
store.pending.append(
{
"id": "abcdef0123456789abcdef0123456789",
"external_user_id": "8615601877957@s.whatsapp.net",
"push_name": "Bob",
"phone": "8615601877957",
"status": "pending",
}
)
inbound = type("_InboundAdminQuoteText", (), {
"channel": "whatsapp",
"external_user_id": "111@s.whatsapp.net",
"external_chat_id": "111@s.whatsapp.net",
"metadata": {
"raw": {
"id": "admin_reply_2",
"participant": "111@s.whatsapp.net",
"quotedStanzaId": "missing_stanza",
"quotedText": "[oclaw] Unauthorized access request\nRequest: abcdef0123456789abcdef0123456789\n",
}
},
})()
out = handle_whatsapp_access(store, inbound=inbound, account_id="wa-default", text="no")
assert out is not None
assert store.pending[0]["status"] == "denied"
assert store.contacts.get("8615601877957", {}).get("list_type") == "blacklist"
def test_handle_whatsapp_access_denied_enqueues_notify_with_pending_source(monkeypatch) -> None:
import json
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _AccessStore()
store.contacts["111@s.whatsapp.net"] = {
"external_user_id": "111@s.whatsapp.net",
"list_type": "admin",
}
out = handle_whatsapp_access(store, inbound=_Inbound(), account_id="wa-default", text="hello")
assert out is not None
assert store.outbound
source = store.outbound[0].get("source")
parsed = json.loads(str(source))
assert parsed.get("kind") == "whatsapp_access_pending"
assert parsed.get("pending_id")
def test_handle_whatsapp_access_allows_admin_via_lid_alias(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _AccessStore()
store.contacts["8618142387786@s.whatsapp.net"] = {
"external_user_id": "8618142387786@s.whatsapp.net",
"phone": "8618142387786",
"list_type": "admin",
}
out = handle_whatsapp_access(store, inbound=_InboundGroup(), account_id="wa-default", text="hello")
assert out is None
def test_handle_whatsapp_access_denied_unknown_user(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _AccessStore()
store.contacts["111@s.whatsapp.net"] = {"external_user_id": "111@s.whatsapp.net", "list_type": "admin"}
out = handle_whatsapp_access(store, inbound=_Inbound(), account_id="wa-default", text="hello")
assert out is not None
assert out.get("whatsapp_access") == "denied"
assert "8615601877957@s.whatsapp.net" not in store.contacts
assert store.pending
assert store.pending[0].get("phone") == "8615601877957"
assert store.outbound
replies = out.get("replies") if isinstance(out.get("replies"), list) else []
assert replies and "denied" in str(replies[0].get("text") or "").lower()
def test_handle_whatsapp_access_allows_whitelisted_user(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _AccessStore()
store.contacts["8615601877957@s.whatsapp.net"] = {
"external_user_id": "8615601877957@s.whatsapp.net",
"phone": "8615601877957",
"list_type": "whitelist",
}
out = handle_whatsapp_access(store, inbound=_Inbound(), account_id="wa-default", text="hello")
assert out is None
assert store.identities.get("8615601877957@s.whatsapp.net")
def test_handle_whatsapp_access_denied_group_includes_mention_and_quote(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _AccessStore()
inbound = type("_InboundGroupDenied", (), {
"channel": "whatsapp",
"is_group": True,
"external_user_id": "333@s.whatsapp.net",
"external_chat_id": "999@g.us",
"metadata": {"raw": {"pushName": "Bob", "id": "stanza_1", "participant": "333@s.whatsapp.net"}},
})()
out = handle_whatsapp_access(store, inbound=inbound, account_id="wa-default", text="hello")
assert out is not None
assert out.get("whatsapp_access") == "denied"
replies = out.get("replies") if isinstance(out.get("replies"), list) else []
assert replies and isinstance(replies[0], dict)
md = replies[0].get("metadata") if isinstance(replies[0].get("metadata"), dict) else {}
assert md.get("mention_jids") == ["333@s.whatsapp.net"]
assert md.get("quote_remote_jid") == "999@g.us"
assert md.get("quote_stanza_id") == "stanza_1"
assert md.get("quote_participant") == "333@s.whatsapp.net"

View file

@ -48,6 +48,12 @@ def test_whatsapp_runner_supports_reply_attachments_base64() -> None:
assert "media_url" in text
def test_whatsapp_runner_resolves_dm_remote_jid_alt() -> None:
text = _read("runtime/operations/whatsapp_bridge/baileys_runner.ts")
assert "resolveDmUserJid" in text
assert "remoteJidAlt" in text
def test_whatsapp_runner_supports_inbound_media_download() -> None:
text = _read("runtime/operations/whatsapp_bridge/baileys_runner.ts")
assert "downloadMediaMessage" in text
@ -58,3 +64,9 @@ def test_whatsapp_runner_supports_inbound_media_download() -> None:
assert "buildOutboundMediaMessage" in text
assert "video_base64" in text
assert "audio_base64" in text
def test_whatsapp_runner_outbound_ack_passes_stanza_id() -> None:
text = _read("runtime/operations/whatsapp_bridge/baileys_runner.ts")
assert "stanza_id" in text
assert "sent as any)?.key?.id" in text