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

0open issues
0open PRs
8items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #3830 fix(whatsapp): resolve LID↔phone aliases in allowlist matching

    merged to main

    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

    Show 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)

    merged to main

    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
  • Closed#2991[WhatsApp] Gateway does not resolve LID to phone number for user authorization
  • Closed#36664[Bug]: WhatsApp LID sender silently dropped — alias resolver reads wrong session dir on modern platforms/ layout
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.00 means 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

At a glance

Subsystem
gateway
Merged PRs
#3830, #54083
Closed issues
2
Attempts
4 PRs closed without merging
Fix link checked
28 Jun 2026

For your agent

The same facts, typed.