mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-15 13:40:51 +08:00
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:
parent
bc1b45a8fd
commit
900e3d0d3e
5 changed files with 581 additions and 10 deletions
|
|
@ -315,6 +315,18 @@ def _build_channel_session_title(*, channel: str, account_name: str, external_us
|
||||||
return f"{ch}|{body}"
|
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:
|
def _parse_generic_inbound(channel_name: str, payload: dict[str, Any]) -> InboundMessage:
|
||||||
meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
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()
|
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:
|
if not account_id:
|
||||||
raise ValueError(f"missing {channel_name} 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()
|
text = inbound.text.strip()
|
||||||
preface = ""
|
preface = ""
|
||||||
channel_session_id = ""
|
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 "绑定失败:无效或已使用的绑定码。"
|
reply = ("绑定成功。\n\n" + _menu_text()) if info else "绑定失败:无效或已使用的绑定码。"
|
||||||
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 = ""
|
||||||
reply_attachments: list[dict[str, Any]] = []
|
reply_attachments: list[dict[str, Any]] = []
|
||||||
ident = store.resolve_user_by_channel_identity_v2(
|
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 "")
|
tenant_id = str(ident.get("tenant_id") or "")
|
||||||
user_id = str(ident.get("user_id") or "")
|
user_id = str(ident.get("user_id") or "")
|
||||||
role = str(ident.get("role") or "member")
|
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
|
account_name = str(account.get("name") or "").strip() or account_id
|
||||||
group_name = _extract_group_name(inbound)
|
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(
|
session_id = store.get_or_create_channel_session_v2(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
channel=inbound.channel,
|
channel=inbound.channel,
|
||||||
account_id=account_id,
|
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,
|
external_chat_id=inbound.external_chat_id,
|
||||||
session_title=_build_channel_session_title(
|
session_title=_build_channel_session_title(
|
||||||
channel=inbound.channel,
|
channel=inbound.channel,
|
||||||
account_name=account_name,
|
account_name=account_name,
|
||||||
external_user_id=inbound.external_user_id,
|
external_user_id=title_user_label,
|
||||||
is_group=inbound.is_group,
|
is_group=inbound.is_group,
|
||||||
group_name=group_name,
|
group_name=group_name,
|
||||||
),
|
),
|
||||||
|
|
@ -770,6 +813,12 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
reply = cmd_reply
|
reply = cmd_reply
|
||||||
elif not reply:
|
elif not reply:
|
||||||
user_text = (inbound.text or "").strip()
|
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(
|
gw_attachments = _channel_attachments_for_gateway(
|
||||||
list(inbound.attachments or [])
|
list(inbound.attachments or [])
|
||||||
)
|
)
|
||||||
|
|
@ -806,6 +855,10 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
"account_id": account_id,
|
"account_id": account_id,
|
||||||
"interaction_mode": interaction_mode,
|
"interaction_mode": interaction_mode,
|
||||||
"selected_specialist": selected_specialist,
|
"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(
|
manager = _build_admin_gateway_executor(
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,11 @@ const PROXY_URL = (
|
||||||
process.env.http_proxy ||
|
process.env.http_proxy ||
|
||||||
""
|
""
|
||||||
).trim();
|
).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 {
|
function log(msg: string): void {
|
||||||
process.stdout.write(`${new Date().toISOString()} [baileys-whatsapp] ${msg}\n`);
|
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");
|
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: {
|
function buildInboundPayload(params: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
text: string;
|
text: string;
|
||||||
raw: unknown;
|
raw: unknown;
|
||||||
|
isGroup: boolean;
|
||||||
|
mentions: string[];
|
||||||
|
groupName?: string;
|
||||||
|
botJid?: string;
|
||||||
}): Json {
|
}): 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 {
|
return {
|
||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
account_id: ACCOUNT_ID,
|
account_id: ACCOUNT_ID,
|
||||||
user_id: params.userId,
|
user_id: params.userId,
|
||||||
chat_id: params.chatId,
|
chat_id: params.chatId,
|
||||||
text: params.text,
|
text: params.text,
|
||||||
metadata: {
|
is_group: params.isGroup,
|
||||||
source: "whatsapp_baileys",
|
mentions: params.mentions,
|
||||||
raw: params.raw,
|
metadata,
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,6 +285,26 @@ async function main(): Promise<void> {
|
||||||
let reconnectAttempt = 0;
|
let reconnectAttempt = 0;
|
||||||
let sock: ReturnType<typeof makeWASocket> | null = null;
|
let sock: ReturnType<typeof makeWASocket> | null = null;
|
||||||
const wsAgent = PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : undefined;
|
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 () => {
|
const connectOnce = async () => {
|
||||||
sock = makeWASocket({
|
sock = makeWASocket({
|
||||||
|
|
@ -300,6 +371,22 @@ async function main(): Promise<void> {
|
||||||
const from = isGroup ? String(key.participant || "").trim() : remoteJid;
|
const from = isGroup ? String(key.participant || "").trim() : remoteJid;
|
||||||
const userId = from ? jidNormalizedUser(from) : jidNormalizedUser(remoteJid);
|
const userId = from ? jidNormalizedUser(from) : jidNormalizedUser(remoteJid);
|
||||||
const chatId = 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 = {
|
const raw = {
|
||||||
id,
|
id,
|
||||||
|
|
@ -309,7 +396,16 @@ async function main(): Promise<void> {
|
||||||
messageTimestamp: (msg as any).messageTimestamp || null,
|
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}`);
|
if (VERBOSE) log(`inbound posting chat=${chatId} user=${userId} textLen=${text.length}`);
|
||||||
const out = await postInbound(inbound);
|
const out = await postInbound(inbound);
|
||||||
const replies = Array.isArray(out.replies) ? (out.replies as Json[]) : [];
|
const replies = Array.isArray(out.replies) ? (out.replies as Json[]) : [];
|
||||||
|
|
|
||||||
135
runtime/orchestration/group_ingest.py
Normal file
135
runtime/orchestration/group_ingest.py
Normal 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",
|
||||||
|
]
|
||||||
|
|
@ -20,9 +20,9 @@ def get_or_create_session_for_wecom(
|
||||||
external_chat_id: str,
|
external_chat_id: str,
|
||||||
is_group: bool,
|
is_group: bool,
|
||||||
) -> SessionMapResult:
|
) -> 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"
|
scope = "group" if bool(is_group) else "dm"
|
||||||
# For DM, external_chat_id is often contact id; we still keep it as provided.
|
# For DM, external_chat_id is often contact id; we still keep it as provided.
|
||||||
|
|
|
||||||
287
tests/test_group_ingest.py
Normal file
287
tests/test_group_ingest.py
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from runtime.orchestration.group_ingest import (
|
||||||
|
GROUP_SESSION_USER_SENTINEL,
|
||||||
|
build_group_sender_context,
|
||||||
|
normalize_jid,
|
||||||
|
resolve_group_policy,
|
||||||
|
session_user_key,
|
||||||
|
should_process_group_inbound,
|
||||||
|
)
|
||||||
|
from runtime.application.gateway.inbound_service import process_inbound_payload
|
||||||
|
from svc.persistence.db.engine import clear_assistant_engine_cache
|
||||||
|
from svc.persistence.sqlite_store import SqliteStore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_sqlite_store(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||||
|
monkeypatch.delenv("AIA_ASSISTANT_DB_BACKEND", raising=False)
|
||||||
|
monkeypatch.setenv("OPS_WORKSPACE_ROOT", str(tmp_path))
|
||||||
|
dbfile = tmp_path / "group_ingest.sqlite"
|
||||||
|
monkeypatch.setenv("AIA_ASSISTANT_DB_PATH", str(dbfile))
|
||||||
|
clear_assistant_engine_cache()
|
||||||
|
s = SqliteStore(str(dbfile))
|
||||||
|
try:
|
||||||
|
yield s
|
||||||
|
finally:
|
||||||
|
clear_assistant_engine_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_jid_strips_device_suffix() -> None:
|
||||||
|
assert normalize_jid("123456:12@s.whatsapp.net") == "123456@s.whatsapp.net"
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_process_direct_messages_always() -> None:
|
||||||
|
assert should_process_group_inbound(is_group=False, text="hi", mentions=[], bot_jid="bot@s.whatsapp.net") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_drop_group_without_mention_or_trigger() -> None:
|
||||||
|
assert (
|
||||||
|
should_process_group_inbound(
|
||||||
|
is_group=True,
|
||||||
|
text="大家好",
|
||||||
|
mentions=[],
|
||||||
|
bot_jid="999@s.whatsapp.net",
|
||||||
|
require_mention=True,
|
||||||
|
triggers=["/oclaw"],
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_accept_group_when_bot_mentioned() -> None:
|
||||||
|
assert (
|
||||||
|
should_process_group_inbound(
|
||||||
|
is_group=True,
|
||||||
|
text="@bot hello",
|
||||||
|
mentions=["999:0@s.whatsapp.net"],
|
||||||
|
bot_jid="999@s.whatsapp.net",
|
||||||
|
require_mention=True,
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_accept_group_trigger_without_mention() -> None:
|
||||||
|
assert (
|
||||||
|
should_process_group_inbound(
|
||||||
|
is_group=True,
|
||||||
|
text="/oclaw 查天气",
|
||||||
|
mentions=[],
|
||||||
|
bot_jid="999@s.whatsapp.net",
|
||||||
|
require_mention=True,
|
||||||
|
triggers=["/oclaw"],
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_user_key_group_sentinel() -> None:
|
||||||
|
assert session_user_key(is_group=True, external_user_id="111@s.whatsapp.net") == GROUP_SESSION_USER_SENTINEL
|
||||||
|
assert session_user_key(is_group=False, external_user_id="111@s.whatsapp.net") == "111@s.whatsapp.net"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_group_policy_from_account_config() -> None:
|
||||||
|
policy = resolve_group_policy(
|
||||||
|
account={
|
||||||
|
"config": {
|
||||||
|
"group_policy": {
|
||||||
|
"require_mention": False,
|
||||||
|
"triggers": ["!ask"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert policy.require_mention is False
|
||||||
|
assert policy.triggers == ("!ask",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_group_sender_context() -> None:
|
||||||
|
ctx = build_group_sender_context(
|
||||||
|
metadata={"raw": {"pushName": "Alice"}},
|
||||||
|
external_user_id="111@s.whatsapp.net",
|
||||||
|
)
|
||||||
|
assert "Alice" in ctx
|
||||||
|
assert "111@s.whatsapp.net" in ctx
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_group_session_for_multiple_senders(fresh_sqlite_store: SqliteStore) -> None:
|
||||||
|
store = fresh_sqlite_store
|
||||||
|
tenant = store.create_tenant("WA")
|
||||||
|
chat_id = "120363012345678@g.us"
|
||||||
|
sid_a = store.get_or_create_channel_session_v2(
|
||||||
|
tenant_id=str(tenant["id"]),
|
||||||
|
channel="whatsapp",
|
||||||
|
account_id="wa-default",
|
||||||
|
external_chat_id=chat_id,
|
||||||
|
external_user_id=session_user_key(is_group=True, external_user_id="111@s.whatsapp.net"),
|
||||||
|
session_title="whatsapp|test+Family",
|
||||||
|
)
|
||||||
|
sid_b = store.get_or_create_channel_session_v2(
|
||||||
|
tenant_id=str(tenant["id"]),
|
||||||
|
channel="whatsapp",
|
||||||
|
account_id="wa-default",
|
||||||
|
external_chat_id=chat_id,
|
||||||
|
external_user_id=session_user_key(is_group=True, external_user_id="222@s.whatsapp.net"),
|
||||||
|
session_title="whatsapp|test+Family",
|
||||||
|
)
|
||||||
|
assert sid_a == sid_b
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_whatsapp_identity(store: SqliteStore, *, extra_user_ids: list[str] | None = None) -> tuple[str, str]:
|
||||||
|
tenant = store.create_tenant("WA")
|
||||||
|
tenant_id = str(tenant["id"])
|
||||||
|
user = store.create_user(tenant_id=tenant_id, display_name="Admin", role="owner")
|
||||||
|
user_id = str(user["id"])
|
||||||
|
store.upsert_user_channel_account(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
channel="whatsapp",
|
||||||
|
account_id="wa-default",
|
||||||
|
name="wa-default",
|
||||||
|
config={"group_policy": {"require_mention": True, "triggers": ["/oclaw"]}},
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
for ext_uid in ["111@s.whatsapp.net", *(extra_user_ids or [])]:
|
||||||
|
store.upsert_channel_identity_v2(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
channel="whatsapp",
|
||||||
|
account_id="wa-default",
|
||||||
|
external_user_id=ext_uid,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
return tenant_id, user_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_inbound_group_without_mention_is_silent(monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore) -> None:
|
||||||
|
store = fresh_sqlite_store
|
||||||
|
_setup_whatsapp_identity(store)
|
||||||
|
monkeypatch.setattr("svc.persistence.assistant_store.get_assistant_store", lambda: store)
|
||||||
|
|
||||||
|
out = process_inbound_payload(
|
||||||
|
{
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"account_id": "wa-default",
|
||||||
|
"user_id": "111@s.whatsapp.net",
|
||||||
|
"chat_id": "120363012345678@g.us",
|
||||||
|
"text": "大家晚上好",
|
||||||
|
"is_group": True,
|
||||||
|
"mentions": [],
|
||||||
|
"metadata": {"bot_jid": "999@s.whatsapp.net", "source": "test"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert out.get("ok") is True
|
||||||
|
assert out.get("replies") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_inbound_dm_still_processes_without_mention(monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore) -> None:
|
||||||
|
store = fresh_sqlite_store
|
||||||
|
_setup_whatsapp_identity(store)
|
||||||
|
monkeypatch.setattr("svc.persistence.assistant_store.get_assistant_store", lambda: store)
|
||||||
|
|
||||||
|
captured: dict[str, str] = {}
|
||||||
|
|
||||||
|
class _Turn:
|
||||||
|
turn_uuid = "turn-1"
|
||||||
|
reply_text = "pong"
|
||||||
|
|
||||||
|
class _Gw:
|
||||||
|
def __init__(self, *, store: object) -> None:
|
||||||
|
_ = store
|
||||||
|
|
||||||
|
def handle_turn(self, **kwargs: object) -> _Turn:
|
||||||
|
msg = kwargs.get("msg")
|
||||||
|
captured["text"] = str(getattr(msg, "text", "") or "")
|
||||||
|
return _Turn()
|
||||||
|
|
||||||
|
monkeypatch.setattr("runtime.gateway.OclawGateway", _Gw)
|
||||||
|
|
||||||
|
out = process_inbound_payload(
|
||||||
|
{
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"account_id": "wa-default",
|
||||||
|
"user_id": "111@s.whatsapp.net",
|
||||||
|
"chat_id": "111@s.whatsapp.net",
|
||||||
|
"text": "ping",
|
||||||
|
"is_group": False,
|
||||||
|
"mentions": [],
|
||||||
|
"metadata": {"bot_jid": "999@s.whatsapp.net"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
replies = out.get("replies") if isinstance(out.get("replies"), list) else []
|
||||||
|
assert replies and str(replies[0].get("text") or "") == "pong"
|
||||||
|
assert "[群成员:" not in captured.get("text", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_inbound_group_mention_uses_shared_session_and_sender_prefix(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore
|
||||||
|
) -> None:
|
||||||
|
store = fresh_sqlite_store
|
||||||
|
tenant_id, user_id = _setup_whatsapp_identity(store, extra_user_ids=["222@s.whatsapp.net"])
|
||||||
|
monkeypatch.setattr("svc.persistence.assistant_store.get_assistant_store", lambda: store)
|
||||||
|
|
||||||
|
captured: dict[str, str] = {}
|
||||||
|
session_ids: list[str] = []
|
||||||
|
|
||||||
|
class _Turn:
|
||||||
|
turn_uuid = "turn-g"
|
||||||
|
reply_text = "group-ok"
|
||||||
|
|
||||||
|
class _Gw:
|
||||||
|
def __init__(self, *, store: object) -> None:
|
||||||
|
_ = store
|
||||||
|
|
||||||
|
def handle_turn(self, **kwargs: object) -> _Turn:
|
||||||
|
msg = kwargs.get("msg")
|
||||||
|
captured["text"] = str(getattr(msg, "text", "") or "")
|
||||||
|
session_ids.append(str(getattr(msg, "session_id", "") or ""))
|
||||||
|
return _Turn()
|
||||||
|
|
||||||
|
monkeypatch.setattr("runtime.gateway.OclawGateway", _Gw)
|
||||||
|
|
||||||
|
chat_id = "120363012345678@g.us"
|
||||||
|
base = {
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"account_id": "wa-default",
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"is_group": True,
|
||||||
|
"metadata": {"bot_jid": "999@s.whatsapp.net", "source": "test"},
|
||||||
|
}
|
||||||
|
|
||||||
|
process_inbound_payload(
|
||||||
|
{
|
||||||
|
**base,
|
||||||
|
"user_id": "111@s.whatsapp.net",
|
||||||
|
"text": "@bot hi",
|
||||||
|
"mentions": ["999@s.whatsapp.net"],
|
||||||
|
"metadata": {**base["metadata"], "raw": {"pushName": "Alice"}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
process_inbound_payload(
|
||||||
|
{
|
||||||
|
**base,
|
||||||
|
"user_id": "222@s.whatsapp.net",
|
||||||
|
"text": "@bot again",
|
||||||
|
"mentions": ["999@s.whatsapp.net"],
|
||||||
|
"metadata": {**base["metadata"], "raw": {"pushName": "Bob"}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(session_ids) == 2
|
||||||
|
assert session_ids[0] == session_ids[1]
|
||||||
|
assert "[群成员:" in captured["text"]
|
||||||
|
assert "Bob" in captured["text"]
|
||||||
|
|
||||||
|
sid = store.get_or_create_channel_session_v2(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
channel="whatsapp",
|
||||||
|
account_id="wa-default",
|
||||||
|
external_chat_id=chat_id,
|
||||||
|
external_user_id=GROUP_SESSION_USER_SENTINEL,
|
||||||
|
session_title="whatsapp|wa-default+Family",
|
||||||
|
)
|
||||||
|
assert sid == session_ids[0]
|
||||||
|
|
||||||
|
_ = tenant_id, user_id
|
||||||
Loading…
Add table
Add a link
Reference in a new issue