# NousResearch/hermes-agent · WhatsApp LID-to-phone alias lookup corrections

## Open now

- 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.

## TL;DR

- 8 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #3830. Verify it addresses your case.
- **Recommended:** read PR #3830 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #2991: \[WhatsApp\] Gateway does not resolve LID to phone number for user authorization
- #36664: \[Bug\]: WhatsApp LID sender silently dropped — alias resolver reads wrong session dir on modern platforms/ layout

## Prior & competing PRs

- **#3830: fix(whatsapp): resolve LID↔phone aliases in allowlist matching**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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.py`
  - fix hunks:
  - `gateway/run.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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);
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)
    ```
- **#39823: fix(gateway): read WhatsApp LID mappings from modern session dir**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#36665`, `#38712`, `#36665`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#54083: fix(whatsapp): resolve LID aliases on modern platforms/ session layout (#36664)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
- **#3097: fix(whatsapp): resolve LID to phone number in gateway authorization**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3e2c8c529`, `3e2c8c529bfeb6ac530bcff1884ce5dc11162e2d`, `62c14d551`, `62c14d5513469e27474fc9535fcdd4afa016646f`, `#3830`
- **#36665: fix(whatsapp): resolve LID aliases on modern platforms/ session layout**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `263ffec1b`, `#54083`
- **#38712: fix(gateway): use get_hermes_dir for WhatsApp LID session path (#36664)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#36664`, `#54083`, `#36665`

## Fix status

- Merged to main in PR #3830 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-28`.
- Subsystem: `gateway`
