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:
committed by
Xubin Ren
parent
e81968a32b
commit
99e158c062
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 == {}
|
||||
|
||||
Reference in New Issue
Block a user