Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions flocks/channel/builtin/whatsapp/bridge/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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);

Expand All @@ -296,7 +344,9 @@ async function startSocket() {
const event = buildBridgeEvent({
msg,
chatId,
chatAltId,
senderId,
senderAltId,
isGroup,
body,
mediaInfo,
Expand Down
14 changes: 12 additions & 2 deletions flocks/channel/builtin/whatsapp/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
30 changes: 26 additions & 4 deletions flocks/channel/builtin/whatsapp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:"):
Expand All @@ -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
Expand Down
10 changes: 7 additions & 3 deletions flocks/channel/builtin/whatsapp/inbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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,
)

20 changes: 18 additions & 2 deletions flocks/channel/inbound/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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,
})
Expand Down
28 changes: 25 additions & 3 deletions tests/channel/test_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
54 changes: 54 additions & 0 deletions tests/channel/test_whatsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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")
Expand Down