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:
|
> 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`
|
> `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>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ class WhatsAppConfig(Base):
|
|||||||
bridge_token: str = ""
|
bridge_token: str = ""
|
||||||
allow_from: list[str] = Field(default_factory=list)
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
group_policy: Literal["open", "mention"] = "open" # "open" responds to all, "mention" only when @mentioned
|
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:
|
def _bridge_token_path() -> Path:
|
||||||
@@ -75,9 +80,39 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
self._ws = None
|
self._ws = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
|
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
|
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:
|
def _effective_bridge_token(self) -> str:
|
||||||
"""Resolve the bridge token, generating a local secret when needed."""
|
"""Resolve the bridge token, generating a local secret when needed."""
|
||||||
if self._bridge_token is not None:
|
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 == [
|
assert sent_messages == [
|
||||||
json.dumps({"type": "auth", "token": token_path.read_text(encoding="utf-8")})
|
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