From 99e158c062f2f09e7c7be0cb4dee99e919eb5e17 Mon Sep 17 00:00:00 2001 From: franciscomaestre Date: Thu, 18 Jun 2026 12:33:19 -0500 Subject: [PATCH] feat(whatsapp): seed LID->phone mappings on startup WhatsApp can deliver a sender LID instead of a phone number. The channel already learns the LID->phone mapping at runtime, but only after a message that carries both values, so the first message from a contact can't be resolved to a phone number. Seed the mapping on startup from two sources: - reverse mapping files the bridge persists in the auth directory (lid-mapping-_reverse.json), resolved via get_runtime_subdir so it respects a custom runtime dir - a new optional channels.whatsapp.lidMappings config dict for static mappings (takes precedence over the on-disk files) Malformed/empty mapping files are ignored rather than failing startup. Adds tests for both sources, precedence, malformed files and the no-auth-dir case, plus docs for the new config field. --- docs/chat-apps.md | 19 +++++++++ nanobot/channels/whatsapp.py | 37 +++++++++++++++- tests/channels/test_whatsapp_channel.py | 56 +++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/docs/chat-apps.md b/docs/chat-apps.md index c46caf23..a76fd1a2 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -336,6 +336,25 @@ nanobot gateway > WhatsApp bridge updates are not applied automatically for existing installations. After upgrading nanobot, rebuild the local bridge with: > `rm -rf ~/.nanobot/bridge && nanobot channels login whatsapp` +**Optional: static LID mappings** + +Modern WhatsApp can deliver a sender's LID instead of their phone number. nanobot +learns the LID→phone mapping at runtime (and reuses the ones the bridge persists on +disk), but you can also seed mappings up front so the phone number resolves from the +very first message: + +```json +{ + "channels": { + "whatsapp": { + "enabled": true, + "allowFrom": ["+1234567890"], + "lidMappings": { "123456789012345": "1234567890" } + } + } +} +``` +
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index a989df03..69edb991 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -30,6 +30,11 @@ class WhatsAppConfig(Base): bridge_token: str = "" allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention"] = "open" # "open" responds to all, "mention" only when @mentioned + # Optional static LID->phone mappings, e.g. {"123456789012345": "15551234567"}. + # Useful to resolve a sender's phone number from the very first message instead of + # only after a message that carries both phone and LID. Merged with mappings the + # bridge persists on disk (lid-mapping-*_reverse.json) under the auth directory. + lid_mappings: dict[str, str] = Field(default_factory=dict) def _bridge_token_path() -> Path: @@ -75,9 +80,39 @@ class WhatsAppChannel(BaseChannel): self._ws = None self._connected = False self._processed_message_ids: OrderedDict[str, None] = OrderedDict() - self._lid_to_phone: dict[str, str] = {} + self._lid_to_phone: dict[str, str] = self._load_lid_mappings() self._bridge_token: str | None = None + def _load_lid_mappings(self) -> dict[str, str]: + """Seed LID->phone mappings on startup. + + Combines two sources so the sender's phone number can be resolved from the + very first message (instead of only after one that carries both phone and LID): + + 1. Reverse mapping files the bridge persists in the auth directory, named + ``lid-mapping-_reverse.json`` and containing the phone number string. + 2. Static ``lid_mappings`` from the channel config (takes precedence). + """ + from nanobot.config.paths import get_runtime_subdir + + mapping: dict[str, str] = {} + auth_dir = get_runtime_subdir("whatsapp-auth") + if auth_dir.is_dir(): + for path in auth_dir.glob("lid-mapping-*_reverse.json"): + lid = path.name[len("lid-mapping-"):-len("_reverse.json")] + try: + phone = json.loads(path.read_text(encoding="utf-8")) + except Exception: + continue + if isinstance(phone, str) and phone.strip(): + mapping[lid] = phone.strip() + + for lid, phone in getattr(self.config, "lid_mappings", {}).items(): + if isinstance(phone, str) and phone.strip(): + mapping[str(lid)] = phone.strip() + + return mapping + def _effective_bridge_token(self) -> str: """Resolve the bridge token, generating a local secret when needed.""" if self._bridge_token is not None: diff --git a/tests/channels/test_whatsapp_channel.py b/tests/channels/test_whatsapp_channel.py index 04d498de..e6bc237b 100644 --- a/tests/channels/test_whatsapp_channel.py +++ b/tests/channels/test_whatsapp_channel.py @@ -449,3 +449,59 @@ async def test_start_sends_auth_message_with_generated_token(monkeypatch, tmp_pa assert sent_messages == [ json.dumps({"type": "auth", "token": token_path.read_text(encoding="utf-8")}) ] + + +# --------------------------------------------------------------------------- +# LID -> phone mapping seeding (startup): static config + bridge reverse files. +# --------------------------------------------------------------------------- + + +def test_lid_mappings_from_config(): + ch = WhatsAppChannel( + {"enabled": True, "lidMappings": {"123456789012345": "15551234567"}}, + MagicMock(), + ) + assert ch._lid_to_phone["123456789012345"] == "15551234567" + + +def test_lid_mappings_from_bridge_reverse_files(tmp_path, monkeypatch): + auth_dir = tmp_path / "whatsapp-auth" + auth_dir.mkdir() + (auth_dir / "lid-mapping-999888777666555_reverse.json").write_text( + json.dumps("15559998888"), encoding="utf-8" + ) + # malformed / empty files must be ignored, not crash startup + (auth_dir / "lid-mapping-broken_reverse.json").write_text("{not json", encoding="utf-8") + (auth_dir / "lid-mapping-empty_reverse.json").write_text(json.dumps(""), encoding="utf-8") + + monkeypatch.setattr( + "nanobot.config.paths.get_runtime_subdir", lambda name: auth_dir + ) + + ch = WhatsAppChannel({"enabled": True}, MagicMock()) + assert ch._lid_to_phone == {"999888777666555": "15559998888"} + + +def test_lid_mappings_config_takes_precedence_over_files(tmp_path, monkeypatch): + auth_dir = tmp_path / "whatsapp-auth" + auth_dir.mkdir() + (auth_dir / "lid-mapping-555_reverse.json").write_text( + json.dumps("from-file"), encoding="utf-8" + ) + monkeypatch.setattr( + "nanobot.config.paths.get_runtime_subdir", lambda name: auth_dir + ) + + ch = WhatsAppChannel( + {"enabled": True, "lidMappings": {"555": "from-config"}}, MagicMock() + ) + assert ch._lid_to_phone["555"] == "from-config" + + +def test_lid_mappings_empty_when_no_auth_dir(tmp_path, monkeypatch): + missing = tmp_path / "does-not-exist" + monkeypatch.setattr( + "nanobot.config.paths.get_runtime_subdir", lambda name: missing + ) + ch = WhatsAppChannel({"enabled": True}, MagicMock()) + assert ch._lid_to_phone == {}