diff --git a/flocks/channel/builtin/whatsapp/bridge/bridge.js b/flocks/channel/builtin/whatsapp/bridge/bridge.js index f215a3e57..0d8d66ed9 100644 --- a/flocks/channel/builtin/whatsapp/bridge/bridge.js +++ b/flocks/channel/builtin/whatsapp/bridge/bridge.js @@ -103,6 +103,35 @@ function normalizeJid(value) { return String(value).replace(/:\d+@/, '@'); } +function stripJid(value) { + const jid = normalizeJid(value); + if (!jid) return ''; + if (jid.includes('@')) return jid.split('@', 1)[0].split(':', 1)[0]; + return jid.replace(/^\+/, ''); +} + +function jidAliases(...values) { + const aliases = []; + const seen = new Set(); + const add = (value) => { + if (Array.isArray(value)) { + for (const item of value) add(item); + return; + } + if (!value) return; + const raw = String(value).trim(); + if (!raw) return; + for (const alias of [raw, normalizeJid(raw), stripJid(raw)]) { + if (alias && !seen.has(alias)) { + aliases.push(alias); + seen.add(alias); + } + } + }; + for (const value of values) add(value); + return aliases; +} + function splitLongMessage(message, limit = 4096) { const text = String(message || ''); if (!text) return []; @@ -192,7 +221,18 @@ async function cacheInboundMedia(msg, mediaInfo) { } } -function buildBridgeEvent({ msg, chatId, senderId, isGroup, body, mediaInfo, mediaPath, fromOwner }) { +function buildBridgeEvent({ + msg, + chatId, + chatAltId, + senderId, + senderAltId, + isGroup, + body, + mediaInfo, + mediaPath, + fromOwner, +}) { const content = getMessageContent(msg); const contextInfo = content.extendedTextMessage?.contextInfo || content.imageMessage?.contextInfo @@ -210,7 +250,11 @@ function buildBridgeEvent({ msg, chatId, senderId, isGroup, body, mediaInfo, med return { messageId: msg.key.id || '', chatId, + chatAltId, senderId, + senderAltId, + senderAliases: jidAliases(senderId, senderAltId), + chatAliases: jidAliases(chatId, chatAltId), senderName: msg.pushName || '', isGroup, body, @@ -272,7 +316,11 @@ async function startSocket() { for (const msg of messages || []) { if (!msg?.message) continue; const chatId = normalizeJid(msg.key.remoteJid); - const senderId = normalizeJid(msg.key.participant || chatId); + const chatAltId = normalizeJid(msg.key.remoteJidAlt || ''); + const participantId = normalizeJid(msg.key.participant || ''); + const participantAltId = normalizeJid(msg.key.participantAlt || ''); + const senderId = normalizeJid(participantId || chatId); + const senderAltId = normalizeJid(participantAltId || (!chatId.endsWith('@g.us') ? chatAltId : '')); const isGroup = chatId.endsWith('@g.us'); const fromMe = Boolean(msg.key.fromMe); @@ -296,7 +344,9 @@ async function startSocket() { const event = buildBridgeEvent({ msg, chatId, + chatAltId, senderId, + senderAltId, isGroup, body, mediaInfo, diff --git a/flocks/channel/builtin/whatsapp/channel.py b/flocks/channel/builtin/whatsapp/channel.py index aefdcb029..8bc58bbd5 100644 --- a/flocks/channel/builtin/whatsapp/channel.py +++ b/flocks/channel/builtin/whatsapp/channel.py @@ -553,12 +553,22 @@ def _is_allowed(self, msg: InboundMessage) -> bool: return False if self._dm_policy == "open": return True - return matches_identifier(msg.sender_id, self._allow_from) + sender_aliases = [] + if isinstance(msg.raw, dict): + raw_aliases = msg.raw.get("senderAliases") + if isinstance(raw_aliases, list): + sender_aliases = raw_aliases + return matches_identifier([msg.sender_id, *sender_aliases], self._allow_from) if self._group_policy == "disabled": return False if self._group_policy == "open": return True - return matches_identifier(msg.chat_id, self._group_allow_from) + chat_aliases = [] + if isinstance(msg.raw, dict): + raw_aliases = msg.raw.get("chatAliases") + if isinstance(raw_aliases, list): + chat_aliases = raw_aliases + return matches_identifier([msg.chat_id, *chat_aliases], self._group_allow_from) def _passes_group_trigger(self, msg: InboundMessage) -> bool: if self._group_trigger == "all": diff --git a/flocks/channel/builtin/whatsapp/config.py b/flocks/channel/builtin/whatsapp/config.py index f9af7441f..40383932a 100644 --- a/flocks/channel/builtin/whatsapp/config.py +++ b/flocks/channel/builtin/whatsapp/config.py @@ -6,7 +6,7 @@ import re import shutil from pathlib import Path -from typing import Any, Optional +from typing import Any, Iterable, Optional DEFAULT_BRIDGE_PORT = 3100 DEFAULT_TEXT_BATCH_DELAY_SECONDS = 3.0 @@ -101,6 +101,28 @@ def strip_jid(value: str) -> str: return raw.lstrip("+") +def identifier_aliases(*values: Any) -> list[str]: + """Return stable WhatsApp identity aliases while preserving order.""" + aliases: list[str] = [] + seen: set[str] = set() + for value in values: + if isinstance(value, (list, tuple, set)): + items: Iterable[Any] = value + else: + items = (value,) + for item in items: + raw = coerce_str(item) + if not raw: + continue + normalized = normalize_jid(raw) + stripped = strip_jid(normalized) + for alias in (raw, normalized, stripped): + if alias and alias not in seen: + aliases.append(alias) + seen.add(alias) + return aliases + + def parse_target(raw: str) -> str: value = coerce_str(raw) for prefix in ("whatsapp:", "wa:", "user:", "group:"): @@ -110,14 +132,14 @@ def parse_target(raw: str) -> str: return normalize_jid(value) -def matches_identifier(candidate: str, allowed: list[str]) -> bool: +def matches_identifier(candidate: str | list[str], allowed: list[str]) -> bool: if not allowed: return False if "*" in allowed: return True - aliases = {candidate, normalize_jid(candidate), strip_jid(candidate)} + aliases = set(identifier_aliases(candidate)) for entry in allowed: - aliases_for_entry = {entry, normalize_jid(entry), strip_jid(entry)} + aliases_for_entry = set(identifier_aliases(entry)) if aliases & aliases_for_entry: return True return False diff --git a/flocks/channel/builtin/whatsapp/inbound.py b/flocks/channel/builtin/whatsapp/inbound.py index 75711850f..f62062540 100644 --- a/flocks/channel/builtin/whatsapp/inbound.py +++ b/flocks/channel/builtin/whatsapp/inbound.py @@ -6,7 +6,7 @@ from flocks.channel.base import ChatType, InboundMessage -from .config import coerce_str, normalize_jid, strip_jid +from .config import coerce_str, identifier_aliases, normalize_jid, strip_jid def _first_media(event: dict[str, Any]) -> tuple[Optional[str], Optional[str]]: @@ -37,6 +37,8 @@ def _resolve_mentioned(event: dict[str, Any], text: str) -> tuple[bool, str]: def build_inbound_message(event: dict[str, Any], account_id: str = "default") -> Optional[InboundMessage]: chat_id = normalize_jid(coerce_str(event.get("chatId"))) sender_id = normalize_jid(coerce_str(event.get("senderId")) or chat_id) + sender_alt_id = normalize_jid(coerce_str(event.get("senderAltId"))) + chat_alt_id = normalize_jid(coerce_str(event.get("chatAltId"))) message_id = coerce_str(event.get("messageId")) if not chat_id or not message_id: return None @@ -51,6 +53,9 @@ def build_inbound_message(event: dict[str, Any], account_id: str = "default") -> sender_name = coerce_str(event.get("senderName")) or None mentioned, mention_text = _resolve_mentioned(event, text) + sender_aliases = identifier_aliases(sender_id, sender_alt_id, event.get("senderAliases")) + chat_aliases = identifier_aliases(chat_id, chat_alt_id, event.get("chatAliases")) + raw = {**event, "senderAliases": sender_aliases, "chatAliases": chat_aliases} return InboundMessage( channel_id="whatsapp", @@ -67,6 +72,5 @@ def build_inbound_message(event: dict[str, Any], account_id: str = "default") -> thread_id=None, mentioned=mentioned, mention_text=mention_text, - raw=event, + raw=raw, ) - diff --git a/flocks/channel/inbound/dispatcher.py b/flocks/channel/inbound/dispatcher.py index 37f38a242..16ff95321 100644 --- a/flocks/channel/inbound/dispatcher.py +++ b/flocks/channel/inbound/dispatcher.py @@ -135,6 +135,22 @@ def _evict_expired(self, now: float) -> None: # Allowlist / authorisation check # ===================================================================== +def _matches_allow_from(msg: InboundMessage, allow_from: list[str]) -> bool: + if msg.channel_id == "whatsapp": + try: + from flocks.channel.builtin.whatsapp.config import matches_identifier + + aliases = [msg.sender_id] + if isinstance(msg.raw, dict): + raw_aliases = msg.raw.get("senderAliases") + if isinstance(raw_aliases, list): + aliases.extend(str(alias) for alias in raw_aliases if str(alias).strip()) + return matches_identifier(aliases, allow_from) + except Exception: + log.warning("allowlist.whatsapp_match_failed", {"sender": msg.sender_id}) + return msg.sender_id in allow_from + + def _check_allowlist(msg: InboundMessage, config: ChannelConfig) -> bool: """Return True if the message is allowed through. @@ -156,12 +172,12 @@ def _check_allowlist(msg: InboundMessage, config: ChannelConfig) -> bool: if not allow_from: log.debug("allowlist.dm_blocked_no_list", {"sender": msg.sender_id}) return False - return msg.sender_id in allow_from + return _matches_allow_from(msg, allow_from) if dm_policy == "pairing": return True return True - if allow_from and msg.sender_id not in allow_from: + if allow_from and not _matches_allow_from(msg, allow_from): log.debug("allowlist.group_blocked", { "sender": msg.sender_id, "chat_id": msg.chat_id, }) diff --git a/tests/channel/test_channel.py b/tests/channel/test_channel.py index 065325060..169132b43 100644 --- a/tests/channel/test_channel.py +++ b/tests/channel/test_channel.py @@ -1059,10 +1059,10 @@ def test_max_size_eviction(self): # ===================================================================== class TestCheckAllowlist: - def _make_msg(self, *, chat_type=ChatType.DIRECT, sender_id="u1"): + def _make_msg(self, *, chat_type=ChatType.DIRECT, sender_id="u1", channel_id="test", raw=None): return InboundMessage( - channel_id="test", account_id="acc", message_id="m1", - sender_id=sender_id, chat_type=chat_type, + channel_id=channel_id, account_id="acc", message_id="m1", + sender_id=sender_id, chat_type=chat_type, raw=raw, ) def test_dm_open_allows_all(self): @@ -1097,6 +1097,28 @@ def test_group_no_allow_from_passes(self): msg = self._make_msg(chat_type=ChatType.GROUP) assert _check_allowlist(msg, cfg) is True + def test_whatsapp_dm_allowlist_matches_lid_alias(self): + from flocks.channel.inbound.dispatcher import _check_allowlist + cfg = ChannelConfig(dm_policy="allowlist", allow_from=["25482795991095@lid"]) + msg = self._make_msg(channel_id="whatsapp", sender_id="25482795991095") + assert _check_allowlist(msg, cfg) is True + + def test_whatsapp_group_allowlist_matches_lid_alias(self): + from flocks.channel.inbound.dispatcher import _check_allowlist + cfg = ChannelConfig(allow_from=["25482795991095@lid"]) + msg = self._make_msg(channel_id="whatsapp", chat_type=ChatType.GROUP, sender_id="25482795991095") + assert _check_allowlist(msg, cfg) is True + + def test_whatsapp_dm_allowlist_matches_sender_aliases(self): + from flocks.channel.inbound.dispatcher import _check_allowlist + cfg = ChannelConfig(dm_policy="allowlist", allow_from=["8618803405095"]) + msg = self._make_msg( + channel_id="whatsapp", + sender_id="25482795991095", + raw={"senderAliases": ["25482795991095@lid", "8618803405095@s.whatsapp.net"]}, + ) + assert _check_allowlist(msg, cfg) is True + class TestFeishuGroupContext: def test_group_override_can_disable_top_level_context_cache(self): diff --git a/tests/channel/test_whatsapp.py b/tests/channel/test_whatsapp.py index ae7366db7..b4beea23c 100644 --- a/tests/channel/test_whatsapp.py +++ b/tests/channel/test_whatsapp.py @@ -8,6 +8,7 @@ from flocks.channel.base import ChatType, OutboundContext from flocks.channel.builtin.whatsapp.channel import WhatsAppChannel from flocks.channel.builtin.whatsapp.config import ( + identifier_aliases, matches_identifier, normalize_jid, parse_target, @@ -57,10 +58,20 @@ def test_strip_jid_and_allowlist_alias_matching(): assert strip_jid("15551234567@s.whatsapp.net") == "15551234567" assert matches_identifier("15551234567@s.whatsapp.net", ["+15551234567"]) assert matches_identifier("abc123@lid", ["abc123"]) + assert matches_identifier(["25482795991095@lid", "8618803405095@s.whatsapp.net"], ["8618803405095"]) assert matches_identifier("120363001234@g.us", ["120363001234@g.us"]) assert not matches_identifier("15550000000@s.whatsapp.net", ["15551234567"]) +def test_identifier_aliases_preserves_lid_and_phone_aliases(): + assert identifier_aliases("25482795991095@lid", "8618803405095@s.whatsapp.net") == [ + "25482795991095@lid", + "25482795991095", + "8618803405095@s.whatsapp.net", + "8618803405095", + ] + + def test_build_inbound_dm_message(): msg = build_inbound_message({ "messageId": "m1", @@ -80,6 +91,33 @@ def test_build_inbound_dm_message(): assert msg.message_id == "15551234567@s.whatsapp.net:m1" +def test_build_inbound_message_preserves_sender_alt_aliases(): + msg = build_inbound_message({ + "messageId": "m1", + "chatId": "25482795991095@lid", + "chatAltId": "8618803405095@s.whatsapp.net", + "senderId": "25482795991095@lid", + "senderAltId": "8618803405095@s.whatsapp.net", + "body": "hello", + "isGroup": False, + }) + + assert msg is not None + assert msg.sender_id == "25482795991095" + assert msg.raw["senderAliases"] == [ + "25482795991095@lid", + "25482795991095", + "8618803405095@s.whatsapp.net", + "8618803405095", + ] + assert msg.raw["chatAliases"] == [ + "25482795991095@lid", + "25482795991095", + "8618803405095@s.whatsapp.net", + "8618803405095", + ] + + def test_build_inbound_group_mention_message(): msg = build_inbound_message({ "messageId": "m2", @@ -117,6 +155,22 @@ def test_build_inbound_media_message(tmp_path: Path): assert msg.media_mime == "image/jpeg" +def test_whatsapp_channel_allowlist_matches_sender_alt_alias(): + channel = WhatsAppChannel() + channel._dm_policy = "allowlist" # type: ignore[attr-defined] + channel._allow_from = ["8618803405095"] # type: ignore[attr-defined] + msg = build_inbound_message({ + "messageId": "m1", + "chatId": "25482795991095@lid", + "senderId": "25482795991095@lid", + "senderAltId": "8618803405095@s.whatsapp.net", + "body": "hello", + }) + + assert msg is not None + assert channel._is_allowed(msg) is True # type: ignore[attr-defined] + + def test_validate_config_requires_valid_mode_and_pairing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): channel = WhatsAppChannel() monkeypatch.setattr("flocks.channel.builtin.whatsapp.channel.find_executable", lambda name: "/usr/bin/node")