feat(whatsapp): gate group replies on mention and share group sessions

Stop replying to every group message by filtering on @bot/triggers in the sidecar and gateway, and map group chats to one shared session while preserving per-sender identity.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-04 18:49:59 +08:00
parent bc1b45a8fd
commit 900e3d0d3e
5 changed files with 581 additions and 10 deletions

View file

@ -315,6 +315,18 @@ def _build_channel_session_title(*, channel: str, account_name: str, external_us
return f"{ch}|{body}"
def _session_title_user_label(*, is_group: bool, external_user_id: str, group_name: str, external_chat_id: str) -> str:
if not is_group:
return str(external_user_id or "").strip() or "unknown"
name = str(group_name or "").strip()
if name:
return name
chat = str(external_chat_id or "").strip()
if chat and "@" in chat:
return chat.split("@", 1)[0] or "group"
return "group"
def _parse_generic_inbound(channel_name: str, payload: dict[str, Any]) -> InboundMessage:
meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
user_id = str(payload.get("user_id") or payload.get("external_user_id") or "").strip()
@ -655,6 +667,19 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
if not account_id:
raise ValueError(f"missing {channel_name} account_id")
account = store.find_user_by_channel_account(channel=inbound.channel, account_id=account_id) or {}
from runtime.orchestration.group_ingest import (
build_group_sender_context,
resolve_group_policy,
session_user_key,
should_process_group_inbound,
)
group_policy = resolve_group_policy(account=account)
bot_jid = None
if isinstance(inbound.metadata, dict):
bot_jid = str(inbound.metadata.get("bot_jid") or "").strip() or None
text = inbound.text.strip()
preface = ""
channel_session_id = ""
@ -673,6 +698,15 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
)
reply = ("绑定成功。\n\n" + _menu_text()) if info else "绑定失败:无效或已使用的绑定码。"
else:
if inbound.is_group and not should_process_group_inbound(
is_group=inbound.is_group,
text=text,
mentions=list(inbound.mentions or []),
bot_jid=bot_jid,
require_mention=group_policy.require_mention,
triggers=list(group_policy.triggers),
):
return {"ok": True, "replies": []}
reply = ""
reply_attachments: list[dict[str, Any]] = []
ident = store.resolve_user_by_channel_identity_v2(
@ -714,19 +748,28 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
tenant_id = str(ident.get("tenant_id") or "")
user_id = str(ident.get("user_id") or "")
role = str(ident.get("role") or "member")
account = store.find_user_by_channel_account(channel=inbound.channel, account_id=account_id) or {}
account_name = str(account.get("name") or "").strip() or account_id
group_name = _extract_group_name(inbound)
session_external_user_id = session_user_key(
is_group=inbound.is_group,
external_user_id=inbound.external_user_id,
)
title_user_label = _session_title_user_label(
is_group=inbound.is_group,
external_user_id=inbound.external_user_id,
group_name=group_name,
external_chat_id=inbound.external_chat_id,
)
session_id = store.get_or_create_channel_session_v2(
tenant_id=tenant_id,
channel=inbound.channel,
account_id=account_id,
external_user_id=inbound.external_user_id,
external_user_id=session_external_user_id,
external_chat_id=inbound.external_chat_id,
session_title=_build_channel_session_title(
channel=inbound.channel,
account_name=account_name,
external_user_id=inbound.external_user_id,
external_user_id=title_user_label,
is_group=inbound.is_group,
group_name=group_name,
),
@ -770,6 +813,12 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
reply = cmd_reply
elif not reply:
user_text = (inbound.text or "").strip()
if inbound.is_group:
sender_ctx = build_group_sender_context(
metadata=inbound.metadata if isinstance(inbound.metadata, dict) else {},
external_user_id=inbound.external_user_id,
)
user_text = f"{sender_ctx}\n{user_text}" if user_text else sender_ctx
gw_attachments = _channel_attachments_for_gateway(
list(inbound.attachments or [])
)
@ -806,6 +855,10 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
"account_id": account_id,
"interaction_mode": interaction_mode,
"selected_specialist": selected_specialist,
"is_group": inbound.is_group,
"external_user_id": inbound.external_user_id,
"external_chat_id": inbound.external_chat_id,
"group_sender_id": inbound.external_user_id,
},
)
manager = _build_admin_gateway_executor(

View file

@ -30,6 +30,11 @@ const PROXY_URL = (
process.env.http_proxy ||
""
).trim();
const GROUP_REQUIRE_MENTION = String(process.env.AIA_WHATSAPP_GROUP_REQUIRE_MENTION ?? "1").trim() !== "0";
const GROUP_TRIGGERS = String(process.env.AIA_WHATSAPP_GROUP_TRIGGERS || "/oclaw,|oclaw")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
function log(msg: string): void {
process.stdout.write(`${new Date().toISOString()} [baileys-whatsapp] ${msg}\n`);
@ -57,22 +62,68 @@ function isStatusOrBroadcastJid(jid: string): boolean {
return low === "status@broadcast" || low.endsWith("@broadcast");
}
function extractMentions(m: proto.IMessage | null | undefined): string[] {
if (!m) return [];
const ctx =
m.extendedTextMessage?.contextInfo ||
m.imageMessage?.contextInfo ||
m.videoMessage?.contextInfo ||
m.documentMessage?.contextInfo ||
null;
const raw = ctx?.mentionedJid;
if (!Array.isArray(raw)) return [];
return raw.map((j) => jidNormalizedUser(String(j || "").trim())).filter(Boolean);
}
function shouldProcessGroupMessage(params: {
isGroup: boolean;
text: string;
mentions: string[];
botJid: string;
}): boolean {
if (!params.isGroup) return true;
const bot = jidNormalizedUser(params.botJid || "");
if (bot) {
const botUser = bot.split("@")[0] || "";
for (const m of params.mentions || []) {
const norm = jidNormalizedUser(String(m || "").trim());
if (!norm) continue;
if (norm === bot) return true;
if (botUser && norm.split("@")[0] === botUser) return true;
}
}
const body = String(params.text || "");
for (const t of GROUP_TRIGGERS) {
if (t && body.includes(t)) return true;
}
return !GROUP_REQUIRE_MENTION;
}
function buildInboundPayload(params: {
chatId: string;
userId: string;
text: string;
raw: unknown;
isGroup: boolean;
mentions: string[];
groupName?: string;
botJid?: string;
}): Json {
const metadata: Json = {
source: "whatsapp_baileys",
raw: params.raw,
};
if (params.groupName) metadata.group_name = params.groupName;
if (params.botJid) metadata.bot_jid = params.botJid;
return {
channel: "whatsapp",
account_id: ACCOUNT_ID,
user_id: params.userId,
chat_id: params.chatId,
text: params.text,
metadata: {
source: "whatsapp_baileys",
raw: params.raw,
},
is_group: params.isGroup,
mentions: params.mentions,
metadata,
};
}
@ -234,6 +285,26 @@ async function main(): Promise<void> {
let reconnectAttempt = 0;
let sock: ReturnType<typeof makeWASocket> | null = null;
const wsAgent = PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : undefined;
const groupNameCache = new Map<string, { name: string; ts: number }>();
const GROUP_NAME_TTL_MS = 10 * 60 * 1000;
const resolveGroupName = async (chatId: string): Promise<string> => {
const key = String(chatId || "").trim();
if (!key) return "";
const cached = groupNameCache.get(key);
const now = Date.now();
if (cached && now - cached.ts < GROUP_NAME_TTL_MS) return cached.name;
const s = sock;
if (!s) return cached?.name || "";
try {
const meta = await s.groupMetadata(key);
const name = String(meta?.subject || "").trim();
groupNameCache.set(key, { name, ts: now });
return name;
} catch {
return cached?.name || "";
}
};
const connectOnce = async () => {
sock = makeWASocket({
@ -300,6 +371,22 @@ async function main(): Promise<void> {
const from = isGroup ? String(key.participant || "").trim() : remoteJid;
const userId = from ? jidNormalizedUser(from) : jidNormalizedUser(remoteJid);
const chatId = jidNormalizedUser(remoteJid);
const mentions = extractMentions(msg.message);
const botJid = sock?.user?.id ? jidNormalizedUser(sock.user.id) : "";
if (
!shouldProcessGroupMessage({
isGroup,
text,
mentions,
botJid,
})
) {
if (VERBOSE) log(`skip group message chat=${chatId} user=${userId} (no mention/trigger)`);
continue;
}
const groupName = isGroup ? await resolveGroupName(chatId) : "";
const raw = {
id,
@ -309,7 +396,16 @@ async function main(): Promise<void> {
messageTimestamp: (msg as any).messageTimestamp || null,
};
const inbound = buildInboundPayload({ chatId, userId, text, raw });
const inbound = buildInboundPayload({
chatId,
userId,
text,
raw,
isGroup,
mentions,
groupName: groupName || undefined,
botJid: botJid || undefined,
});
if (VERBOSE) log(`inbound posting chat=${chatId} user=${userId} textLen=${text.length}`);
const out = await postInbound(inbound);
const replies = Array.isArray(out.replies) ? (out.replies as Json[]) : [];

View file

@ -0,0 +1,135 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
GROUP_SESSION_USER_SENTINEL = "__group__"
def normalize_jid(jid: str) -> str:
s = str(jid or "").strip().lower()
if not s:
return ""
if "@" in s:
user, domain = s.split("@", 1)
user = user.split(":")[0]
return f"{user}@{domain}"
return s.split(":")[0]
def normalize_jids(jids: list[str]) -> set[str]:
out: set[str] = set()
for raw in jids or []:
n = normalize_jid(raw)
if n:
out.add(n)
return out
def session_user_key(*, is_group: bool, external_user_id: str) -> str:
return GROUP_SESSION_USER_SENTINEL if is_group else str(external_user_id or "").strip()
@dataclass(frozen=True)
class GroupPolicyConfig:
require_mention: bool = True
triggers: tuple[str, ...] = ("/oclaw",)
session_scope: str = "chat"
def _parse_bool_env(name: str, default: bool) -> bool:
raw = str(os.environ.get(name) or "").strip().lower()
if not raw:
return default
return raw not in {"0", "false", "no", "off"}
def _parse_triggers_env(name: str, default: tuple[str, ...]) -> tuple[str, ...]:
raw = str(os.environ.get(name) or "").strip()
if not raw:
return default
parts = [p.strip() for p in raw.split(",") if p.strip()]
return tuple(parts) if parts else default
def _parse_group_policy_dict(raw: Any) -> GroupPolicyConfig | None:
if not isinstance(raw, dict):
return None
require_mention = raw.get("require_mention")
triggers_raw = raw.get("triggers")
session_scope = raw.get("session_scope")
triggers: tuple[str, ...] | None = None
if isinstance(triggers_raw, list):
triggers = tuple(str(x).strip() for x in triggers_raw if str(x).strip())
return GroupPolicyConfig(
require_mention=bool(require_mention) if require_mention is not None else True,
triggers=triggers if triggers is not None else ("/oclaw",),
session_scope=str(session_scope or "chat").strip() or "chat",
)
def resolve_group_policy(*, account: dict[str, Any] | None = None) -> GroupPolicyConfig:
cfg = (account or {}).get("config")
if isinstance(cfg, dict):
gp = _parse_group_policy_dict(cfg.get("group_policy"))
if gp is not None:
return gp
gp = _parse_group_policy_dict(cfg.get("group"))
if gp is not None:
return gp
return GroupPolicyConfig(
require_mention=_parse_bool_env("AIA_WHATSAPP_GROUP_REQUIRE_MENTION", True),
triggers=_parse_triggers_env("AIA_WHATSAPP_GROUP_TRIGGERS", ("/oclaw", "|oclaw")),
)
def should_process_group_inbound(
*,
is_group: bool,
text: str,
mentions: list[str],
bot_jid: str | None,
require_mention: bool = True,
triggers: list[str] | tuple[str, ...] | None = None,
) -> bool:
if not is_group:
return True
mention_set = normalize_jids(list(mentions or []))
bot = normalize_jid(str(bot_jid or ""))
if bot and bot in mention_set:
return True
# Baileys may omit bot from mentionedJid when user uses display-name @; compare user part.
if bot and "@" in bot:
bot_user = bot.split("@", 1)[0]
for m in mention_set:
if "@" in m and m.split("@", 1)[0] == bot_user:
return True
trigger_list = [str(t) for t in (triggers or []) if str(t)]
body = str(text or "")
if trigger_list and any(t in body for t in trigger_list):
return True
return not require_mention
def build_group_sender_context(*, metadata: dict[str, Any] | None, external_user_id: str) -> str:
meta = metadata if isinstance(metadata, dict) else {}
raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
push_name = str(raw.get("pushName") or meta.get("push_name") or meta.get("display_name") or "").strip()
sender = str(external_user_id or "").strip()
label = push_name or sender or "unknown"
if sender and push_name and sender not in push_name:
return f"[群成员: {label} ({sender})]"
return f"[群成员: {label}]"
__all__ = [
"GROUP_SESSION_USER_SENTINEL",
"GroupPolicyConfig",
"build_group_sender_context",
"normalize_jid",
"normalize_jids",
"resolve_group_policy",
"session_user_key",
"should_process_group_inbound",
]

View file

@ -20,9 +20,9 @@ def get_or_create_session_for_wecom(
external_chat_id: str,
is_group: bool,
) -> SessionMapResult:
"""Group: session per chat_id; DM: session per user_id.
"""Group chats share one session per external_chat_id (sentinel user key).
We store mapping under (tenant, channel, external_chat_id, external_user_id) to keep it simple.
Direct chats use one session per external_user_id. Identity binding always uses the real sender id.
"""
scope = "group" if bool(is_group) else "dm"
# For DM, external_chat_id is often contact id; we still keep it as provided.