feat(netx-bridge): WhatsApp proactive alerts from NetX key alarms

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-22 11:17:32 +08:00
parent 9b6bf1d4eb
commit 7210293f42
9 changed files with 747 additions and 1 deletions

View file

@ -1301,6 +1301,118 @@ def build_admin_router() -> APIRouter:
)
return {"ok": True, "deleted": deleted}
@router.get("/admin/api/whatsapp/groups")
def api_whatsapp_groups(
tenant_id: str = Query(default="default"),
account_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:read")
tid = str(tenant_id or "default").strip()
aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
known = store.list_whatsapp_known_groups(tenant_id=tid, account_id=aid)
session_groups: list[dict[str, Any]] = []
try:
with store._connect() as conn:
rows = conn.execute(
"""
SELECT DISTINCT external_chat_id, session_id
FROM channel_session_v2
WHERE tenant_id = ? AND channel = 'whatsapp' AND account_id = ? AND external_chat_id LIKE '%@g.us'
""",
(tid, aid),
).fetchall()
for r in rows:
jid = str(r[0] or "").strip()
if jid:
session_groups.append({"group_jid": jid, "session_id": str(r[1] or "")})
except Exception:
session_groups = []
merged: dict[str, dict[str, Any]] = {}
for g in known:
jid = str(g.get("group_jid") or "").strip()
if jid:
merged[jid] = g
for sg in session_groups:
jid = str(sg.get("group_jid") or "").strip()
if jid and jid not in merged:
merged[jid] = {
"tenant_id": tid,
"account_id": aid,
"group_jid": jid,
"group_name": "",
"last_seen_at": "",
"session_id": sg.get("session_id"),
}
binding = store.get_whatsapp_alert_binding(tenant_id=tid, account_id=aid)
return {"ok": True, "items": list(merged.values()), "binding": binding}
@router.get("/admin/api/whatsapp/alert-binding")
def api_whatsapp_alert_binding_get(
tenant_id: str = Query(default="default"),
account_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:read")
tid = str(tenant_id or "default").strip()
aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
binding = store.get_whatsapp_alert_binding(tenant_id=tid, account_id=aid)
return {"ok": True, "binding": binding}
@router.post("/admin/api/whatsapp/alert-binding")
def api_whatsapp_alert_binding_upsert(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
tid = str(payload.get("tenant_id") or "default").strip()
aid = str(payload.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
group_jid = str(payload.get("group_jid") or "").strip()
if not group_jid:
return {"ok": False, "error": "group_jid_required"}
binding = store.upsert_whatsapp_alert_binding(
tenant_id=tid,
account_id=aid,
group_jid=group_jid,
group_name=str(payload.get("group_name") or "").strip(),
enabled=bool(payload.get("enabled", True)),
)
return {"ok": True, "binding": binding}
@router.post("/admin/api/whatsapp/alert-binding/test")
def api_whatsapp_alert_binding_test(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
tid = str(payload.get("tenant_id") or "default").strip()
aid = str(payload.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
binding = store.get_whatsapp_alert_binding(tenant_id=tid, account_id=aid)
if not binding or not bool(binding.get("enabled")):
return {"ok": False, "error": "binding_missing"}
group_jid = str(binding.get("group_jid") or "").strip()
if not group_jid:
return {"ok": False, "error": "group_jid_missing"}
msg_id = store.enqueue_channel_outbound_message(
channel="whatsapp",
chat_id=group_jid,
text="[OClaw] WhatsApp 告警群绑定测试消息",
tenant_id=tid,
account_id=aid,
source="admin.test",
)
return {"ok": True, "outbound_id": msg_id}
@router.get("/admin/api/users")
def api_users(
tenant_id: str,

View file

@ -1472,7 +1472,7 @@ function markPrewarmReminder(reason) {
}
async function renderStack() {
const [st, anomaliesResp, scanResp, prewarmStatusResp, prewarmPromptsResp, channelSpecResp, weixinDispatchResp, whatsappDispatchResp] = await Promise.all([
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"),
@ -1481,6 +1481,7 @@ async function renderStack() {
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 requiredServices = ["gateway", "channel:wecom"];
const runningNames = new Set(
@ -1582,6 +1583,68 @@ async function renderStack() {
};
const weixinDispatchCard = createChannelDispatchCard("weixin", "Weixin dispatch", weixinDispatchResp || {});
const whatsappDispatchCard = createChannelDispatchCard("whatsapp", "WhatsApp dispatch", whatsappDispatchResp || {});
const waGroups = Array.isArray(whatsappGroupsResp && whatsappGroupsResp.items) ? whatsappGroupsResp.items : [];
const waBinding = (whatsappGroupsResp && whatsappGroupsResp.binding) || {};
const waGroupSel = el(
"select",
{ class: "input" },
[
el("option", { value: "", text: currentLang === "zh" ? "选择群…" : "Select group…" }),
...waGroups.map((g) => {
const jid = String((g && g.group_jid) || "").trim();
const name = String((g && g.group_name) || "").trim();
const label = name ? `${name} (${jid})` : jid;
return el("option", {
value: jid,
text: label || jid,
selected: jid && jid === String(waBinding.group_jid || "") ? "selected" : undefined,
});
}),
],
);
const waBindingStatus = el("div", {
class: "muted",
text: waBinding.group_jid
? `binding=${waBinding.group_jid} enabled=${Boolean(waBinding.enabled)}`
: (currentLang === "zh" ? "尚未绑定告警群" : "No alert group bound"),
});
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." }),
el("div", { class: "row" }, [waGroupSel]),
el("div", { class: "row" }, [
el("button", {
class: "btn btn--primary",
text: currentLang === "zh" ? "保存绑定" : "Save binding",
onclick: async () => {
const group_jid = String(waGroupSel.value || "").trim();
if (!group_jid) return;
const picked = waGroups.find((g) => String((g && g.group_jid) || "").trim() === group_jid) || {};
const resp = await apiPost("/admin/api/whatsapp/alert-binding", {
tenant_id: "default",
group_jid,
group_name: String((picked && picked.group_name) || ""),
enabled: true,
});
const b = (resp && resp.binding) || {};
waBindingStatus.textContent = b.group_jid
? `binding=${b.group_jid} enabled=${Boolean(b.enabled)}`
: (currentLang === "zh" ? "绑定失败" : "Bind failed");
},
}),
el("button", {
class: "btn",
text: currentLang === "zh" ? "测试推送" : "Test push",
onclick: async () => {
const resp = await apiPost("/admin/api/whatsapp/alert-binding/test", { tenant_id: "default" });
waBindingStatus.textContent = resp && resp.ok
? `test ok outbound_id=${String(resp.outbound_id || "")}`
: `test failed: ${String((resp && resp.error) || "unknown")}`;
},
}),
]),
waBindingStatus,
]);
const cleanupStatus = el("div", { class: "muted", text: "" });
const btnCleanup = el("button", { class: "btn btn--danger", text: t("stack.cleanup"), onclick: async () => {
const resp = await apiPost("/admin/api/runtime/cleanup", {});
@ -1773,6 +1836,7 @@ async function renderStack() {
]),
weixinDispatchCard,
whatsappDispatchCard,
whatsappAlertBindingCard,
el("div", { class: "card" }, [
el("div", { class: "card__title", text: currentLang === "zh" ? "提示词/工具预热" : "Prompt/Tool Prewarm" }),
el("div", { class: "muted", text: prewarmSummary }),