WhatsApp LID-to-phone alias lookup corrections
NousResearch/hermes-agent · cluster 2991 · gateway
Data as of
Nothing open on GitHub. Fix merged in #3830. Check it covers your case.
The short answer
- Known?
- Yes. 8 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #3830. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple issues and corrections addressing WhatsApp LID (Linked ID) not being mapped to phone numbers in gateway authorization, allowlist matching, and session directory lookups on modern platform layouts.
Open issues0
Open PRs0
Fixes on record
-
#3830 fix(whatsapp): resolve LID↔phone aliases in allowlist matching
files:
gateway/run.py,scripts/whatsapp-bridge/allowlist.js,scripts/whatsapp-bridge/allowlist.test.mjs,scripts/whatsapp-bridge/bridge.js,tests/gateway/test_unauthorized_dm_behavior.pyShow the diff
-
gateway/run.py@@ -225,6 +225,49 @@ def _ensure_ssl_certs() -> None: from gateway.delivery import DeliveryRouter from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType + +def _normalize_whatsapp_identifier(value: str) -> str: + """Strip WhatsApp JID/LID syntax down to its stable numeric identifier.""" + return ( + str(value or "") + .strip() + .replace("+", "", 1) + .split(":", 1)[0] + .split("@", 1)[0] + ) + + +def _expand_whatsapp_auth_aliases(identifier: str) -> set: + """Resolve WhatsApp phone/LID aliases using bridge session mapping files.""" + normalized = _normalize_whatsapp_identifier(identifier) + if not normalized: + return set() + + session_dir = _hermes_home / "whatsapp" / "session" + resolved = set() + queue = [normalized] + + while queue: + current = queue.pop(0) + if not current or current in resolved: + continue + + resolved.add(current) + for suffix in ("", "_reverse"): + mapping_path = session_dir / f"lid-mapping-{current}{suffix}.json" + if not mapping_path.exists(): + continue + try: + mapped = _normalize_whatsapp_identifier( + json.loads(mapping_path.read_text(encoding="utf-8")) + ) + except Exception: + continue + if mapped and mapped not in resolved: + queue.append(mapped) + + return resolved + logger = logging.getLogger(__name__) # Sentinel placed into _running_agents immediately when a session starts @@ -1539,10 +1582,23 @@ def _is_user_authorized(self, source: SessionSource) -> bool: if global_allowlist: allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip()) - # WhatsApp JIDs have @s.whatsapp.net suffix — strip it for comparison check_ids = {user_id} if "@" in user_id: check_ids.add(user_id.split("@")[0]) + + # WhatsApp: resolve phone↔LID aliases from bridge session mapping files + if source.platform == Platform.WHATSAPP: + normalized_allowed_ids = set() + for allowed_id in allowed_ids: + normalized_allowed_ids.update(_expand_whatsapp_auth_aliases(allowed_id)) + if normalized_allowed_ids: + allowed_ids = normalized_allowed_ids + + check_ids.update(_expand_whatsapp_auth_aliases(user_id)) + normalized_user_id = _normalize_whatsapp_identifier(user_id) + if normalized_user_id: + check_ids.add(normalized_user_id) + return bool(check_ids & allowed_ids) def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str: -
scripts/whatsapp-bridge/allowlist.js@@ -0,0 +1,79 @@ +import path from 'path'; +import { existsSync, readFileSync } from 'fs'; + +export function normalizeWhatsAppIdentifier(value) { + return String(value || '') + .trim() + .replace(/:.*@/, '@') + .replace(/@.*/, '') + .replace(/^\+/, ''); +} + +export function parseAllowedUsers(rawValue) { + return new Set( + String(rawValue || '') + .split(',') + .map((value) => normalizeWhatsAppIdentifier(value)) + .filter(Boolean) + ); +} + +function readMappingFile(sessionDir, identifier, suffix = '') { + const filePath = path.join(sessionDir, `lid-mapping-${identifier}${suffix}.json`); + if (!existsSync(filePath)) { + return null; + } + + try { + const parsed = JSON.parse(readFileSync(filePath, 'utf8')); + const normalized = normalizeWhatsAppIdentifier(parsed); + return normalized || null; + } catch { + return null; + } +} + +export function expandWhatsAppIdentifiers(identifier, sessionDir) { + const normalized = normalizeWhatsAppIdentifier(identifier); + if (!normalized) { + return new Set(); + } + + // Walk both phone->LID and LID->phone mapping files so allowlists can use + // either form transparently in bot mode. + const resolved = new Set(); + const queue = [normalized]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || resolved.has(current)) { + continue; + } + + resolved.add(current); + + for (const suffix of ['', '_reverse']) { + const mapped = readMappingFile(sessionDir, current, suffix); + if (mapped && !resolved.has(mapped)) { + queue.push(mapped); + } + } + } + + return resolved; +} + +export function matchesAllowedUser(senderId, allowedUsers, sessionDir) { + if (!allowedUsers || allowedUsers.size === 0) { + return true; + } + + const aliases = expandWhatsAppIdentifiers(senderId, sessionDir); + for (const alias of aliases) { + if (allowedUsers.has(alias)) { + return true; + } + } + + return false; +} -
scripts/whatsapp-bridge/allowlist.test.mjs@@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; + +import { + expandWhatsAppIdentifiers, + matchesAllowedUser, + normalizeWhatsAppIdentifier, + parseAllowedUsers, +} from './allowlist.js'; + +test('normalizeWhatsAppIdentifier strips jid syntax and plus prefix', () => { + assert.equal(normalizeWhatsAppIdentifier('[redacted:email]'), '19175395595'); + assert.equal(normalizeWhatsAppIdentifier('267383306489914@lid'), '267383306489914'); + assert.equal(normalizeWhatsAppIdentifier('19175395595:[redacted:email]'), '19175395595'); +}); + +test('expandWhatsAppIdentifiers resolves phone and lid aliases from session files', () => { + const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-')); + + try { + writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914')); + writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595')); + + const aliases = expandWhatsAppIdentifiers('267383306489914@lid', sessionDir); + assert.deepEqual([...aliases].sort(), ['19175395595', '267383306489914']); + } finally { + rmSync(sessionDir, { recursive: true, force: true }); + } +}); + +test('matchesAllowedUser accepts mapped lid sender when allowlist only contains phone number', () => { + const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-')); + + try { + writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914')); + writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595')); + + const allowedUsers = parseAllowedUsers('+19175395595'); + assert.equal(matchesAllowedUser('267383306489914@lid', allowedUsers, sessionDir), true); + assert.equal(matchesAllowedUser('188012763865257@lid', allowedUsers, sessionDir), false); + } finally { + rmSync(sessionDir, { recursive: true, force: true }); + } +}); -
scripts/whatsapp-bridge/bridge.js@@ -26,6 +26,7 @@ import path from 'path'; import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs'; import { randomBytes } from 'crypto'; import qrcode from 'qrcode-terminal'; +import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js'; // Parse CLI args const args = process.argv.slice(2); @@ -47,7 +48,7 @@ const DOCUMENT_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'docume const AUDIO_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'audio_cache'); const PAIR_ONLY = args.includes('--pair-only'); const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat" -const ALLOWED_USERS = (process.env.WHATSAPP_ALLOWED_USERS || '').split(',').map(s => s.trim()).filter(Boolean); +const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || ''); const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\n────────────\n'; const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined ? DEFAULT_REPLY_PREFIX @@ -190,10 +191,9 @@ async function startSocket() { if (!isSelfChat) continue; } - // Check allowlist for messages from others (resolve LID → phone if needed) - if (!msg.key.fromMe && ALLOWED_USERS.length > 0) { - const resolvedNumber = lidToPhone[senderNumber] || senderNumber; - if (!ALLOWED_USERS.includes(resolvedNumber)) continue; + // Check allowlist for messages from others (resolve LID ↔ phone aliases) + if (!msg.key.fromMe && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) { + continue; } // Extract message body @@ -515,8 +515,8 @@ if (PAIR_ONLY) { app.listen(PORT, '127.0.0.1', () => { console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`); console.log(`📁 Session stored in: ${SESSION_DIR}`); - if (ALLOWED_USERS.length > 0) { - console.log(`🔒 Allowed users: ${ALLOWED_USERS.join(', ')}`); + if (ALLOWED_USERS.size > 0) { + console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`); } else { console.log(`⚠️ No WHATSAPP_ALLOWED_USERS set — all messages will be processed`); } -
tests/gateway/test_unauthorized_dm_behavior.py@@ -3,6 +3,7 @@ import pytest +import gateway.run as gateway_run from gateway.config import GatewayConfig, Platform, PlatformConfig from gateway.platforms.base import MessageEvent from gateway.session import SessionSource @@ -62,6 +63,32 @@ def _make_runner(platform: Platform, config: GatewayConfig): return runner, adapter +def test_whatsapp_lid_user_matches_phone_allowlist_via_session_mapping(monkeypatch, tmp_path): + _clear_auth_env(monkeypatch) + monkeypatch.setenv("WHATSAPP_ALLOWED_USERS", "15550000001") + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + session_dir = tmp_path / "whatsapp" / "session" + session_dir.mkdir(parents=True) + (session_dir / "lid-mapping-15550000001.json").write_text('"900000000000001"', encoding="utf-8") + (session_dir / "lid-mapping-900000000000001_reverse.json").write_text('"15550000001"', encoding="utf-8") + + runner, _adapter = _make_runner( + Platform.WHATSAPP, + GatewayConfig(platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)}), + ) + + source = SessionSource( + platform=Platform.WHATSAPP, + user_id="900000000000001@lid", + chat_id="900000000000001@lid", + user_name="tester", + chat_type="dm", + ) + + assert runner._is_user_authorized(source) is True + + @pytest.mark.asyncio async def test_unauthorized_dm_pairs_by_default(monkeypatch): _clear_auth_env(monkeypatch)
-
-
#54083 fix(whatsapp): resolve LID aliases on modern platforms/ session layout (#36664)
files:
gateway/whatsapp_identity.py,infographic/whatsapp-lid-session-fix/infographic.png,tests/gateway/test_unauthorized_dm_behavior.py,tests/gateway/test_whatsapp_identity.py
4 PRs closed without merging
From the close comments: merged via another change 2 · done elsewhere 1 · other 1
-
#3097 fix(whatsapp): resolve LID to phone number in gateway authorization · done elsewhere →
3e2c8c5,62c14d5,#3830 -
#36665 fix(whatsapp): resolve LID aliases on modern platforms/ session layout · merged via another change →
263ffec,#54083 -
#38712 fix(gateway): use get_hermes_dir for WhatsApp LID session path (#36664) · merged via another change →
#36664,#54083,#36665 -
#39823 fix(gateway): read WhatsApp LID mappings from modern session dir · other →
#36665,#38712
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.23: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab