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-<lid>_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.
This commit is contained in:
franciscomaestre
2026-06-21 13:05:06 +08:00
committed by Xubin Ren
parent e81968a32b
commit 99e158c062
3 changed files with 111 additions and 1 deletions
+19
View File
@@ -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" }
}
}
}
```
</details>
<details>
+36 -1
View File
@@ -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-<lid>_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:
+56
View File
@@ -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 == {}