From 900e3d0d3ec85ef023fd1d7aa12495f57d60f926 Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 4 Jun 2026 18:49:59 +0800 Subject: [PATCH] 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 --- .../application/gateway/inbound_service.py | 59 +++- .../whatsapp_bridge/baileys_runner.ts | 106 ++++++- runtime/orchestration/group_ingest.py | 135 ++++++++ runtime/orchestration/session_mapping.py | 4 +- tests/test_group_ingest.py | 287 ++++++++++++++++++ 5 files changed, 581 insertions(+), 10 deletions(-) create mode 100644 runtime/orchestration/group_ingest.py create mode 100644 tests/test_group_ingest.py diff --git a/runtime/application/gateway/inbound_service.py b/runtime/application/gateway/inbound_service.py index 8773970c..1e054ed6 100644 --- a/runtime/application/gateway/inbound_service.py +++ b/runtime/application/gateway/inbound_service.py @@ -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( diff --git a/runtime/operations/whatsapp_bridge/baileys_runner.ts b/runtime/operations/whatsapp_bridge/baileys_runner.ts index 34880a6c..7c2ce605 100644 --- a/runtime/operations/whatsapp_bridge/baileys_runner.ts +++ b/runtime/operations/whatsapp_bridge/baileys_runner.ts @@ -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 { let reconnectAttempt = 0; let sock: ReturnType | null = null; const wsAgent = PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : undefined; + const groupNameCache = new Map(); + const GROUP_NAME_TTL_MS = 10 * 60 * 1000; + + const resolveGroupName = async (chatId: string): Promise => { + 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 { 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 { 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[]) : []; diff --git a/runtime/orchestration/group_ingest.py b/runtime/orchestration/group_ingest.py new file mode 100644 index 00000000..a6a75a27 --- /dev/null +++ b/runtime/orchestration/group_ingest.py @@ -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", +] diff --git a/runtime/orchestration/session_mapping.py b/runtime/orchestration/session_mapping.py index f55379b6..deef9d13 100644 --- a/runtime/orchestration/session_mapping.py +++ b/runtime/orchestration/session_mapping.py @@ -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. diff --git a/tests/test_group_ingest.py b/tests/test_group_ingest.py new file mode 100644 index 00000000..9abbef70 --- /dev/null +++ b/tests/test_group_ingest.py @@ -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