Identity Canonicalization
Reconcile alternate platform address forms to one stable identity
Some platforms address the same person with two interchangeable ids — WhatsApp uses both <lid>@lid and <phone>@s.whatsapp.net. Identity canonicalization maps every alternate form to one stable id before the gateway keys the session, memory, allowlist, and pairing on it, so one person is never split into two principals.
Quick Start
- WhatsApp: nothing to configure
WhatsAppBotwiresWhatsAppIdentityCanonicalizeron the web-mode message path automatically. LID and phone forms of the same person resolve to one identity — no flags, no config.bash praisonai bot whatsapp --mode web - Implement it for another platform
The extension point is a dependency-free, runtime-checkable
Protocol. Implementcanonicalizeand return the stable id — or the raw id unchanged when you don’t know it.python from praisonaiagents.gateway import IdentityCanonicalizerProtocol class MyPlatformCanonicalizer: def __init__(self): self._map: dict[str, str] = {} def canonicalize(self, platform: str, raw_user_id: str) -> str: # Return the stable canonical id, or raw_user_id when unknown (fail-open). return self._map.get(raw_user_id, raw_user_id) # Runtime-checkable — no base class to inherit assert isinstance(MyPlatformCanonicalizer(), IdentityCanonicalizerProtocol) - Wire it into the session for any platform
Canonicalization runs at the general session-key path (
BotSessionManager._storage_key()), so every platform benefits — not just WhatsApp. Inject a canonicalizer at runtime throughGatewayRuntimeSeams.python from praisonaiagents.bots import GatewayRuntimeSeams from praisonai_bot.bots._session import BotSessionManager session = BotSessionManager(platform="myplatform") session.attach_gateway_runtime( GatewayRuntimeSeams(identity_canonicalizer=MyPlatformCanonicalizer()) ) - Use the adapter as its own canonicalizer
BasePlatformAdapternow ships a defaultcanonicalize()(identity function), so any adapter structurally is anIdentityCanonicalizerProtocol. Override it to reconcile alias formats, then hand the adapter itself toidentity_canonicalizer=.python from praisonaiagents.bots import BasePlatformAdapter from praisonaiagents.gateway import IdentityCanonicalizerProtocol from praisonai_bot.bots._session import BotSessionManager class MyPlatformAdapter(BasePlatformAdapter): def canonicalize(self, platform: str, raw_user_id: str) -> str: # override to reconcile alias formats; default is identity return self._alias_map.get(raw_user_id, raw_user_id) # A vanilla BasePlatformAdapter subclass is already a valid canonicalizer. adapter = MyPlatformAdapter() assert isinstance(adapter, IdentityCanonicalizerProtocol) # Pass the adapter itself where the protocol is accepted. session = BotSessionManager( platform="myplatform", identity_canonicalizer=adapter, )
How It Works
Canonicalization is the first step of identity resolution. Every downstream key — session, memory, allowlist, pairing — is derived from the reconciled id, so alternate address forms of the same person collapse to one principal.
The contract is deliberately small and safe:
- Deterministic. The same input always maps to the same output.
- Fail-open. An unknown address returns unchanged, so a missing mapping is never worse than passing the raw id through. With no canonicalizer registered, resolution is unchanged.
- First in line. It runs before any key is derived, so the allowlist and session see the reconciled id — not the raw one.
Order of operations in _storage_key()
The session-key path stabilises the id first, then resolves it:
canonical_user_id = self._canonical_user_id(user_id)— runs first, fail-open on error/empty.base = identity_resolver.resolve(platform, canonical_user_id)— runs on the stabilised id.basebecomes the session key.
WhatsApp implementation
WhatsAppIdentityCanonicalizer learns the LID↔phone mapping from whatsmeow’s SenderAlt alternate-JID field and reconciles the sender toward the phone form. It only accepts an exact @lid ↔ @s.whatsapp.net pairing — a group @g.us, an unexpected domain, or an LID-to-LID pair is rejected, so a malformed or hostile alternate JID never stores a bogus identity.
| Behaviour | Result |
|---|---|
| LID form of an allowlisted phone number arrives | Matches the allowlist (reconciled to phone form) |
| LID-form and phone-form turns from one person | One session and memory bucket |
| Already-paired user arrives in the other form | No re-pairing prompt |
| LID-addressed self-chat | Still detected as self-chat, not dropped as outgoing |
| Unknown or malformed alternate JID | Returned unchanged (fail-open) |
See WhatsApp Bot → Identity: LID vs phone JID.
Protocol Reference
Import from the gateway package:
from praisonaiagents.gateway import IdentityCanonicalizerProtocol| Method | Signature | Contract |
|---|---|---|
canonicalize | canonicalize(platform: str, raw_user_id: str) -> str | Deterministic; MUST return raw_user_id unchanged when no reconciliation is known (fail-open). |
IdentityCanonicalizerProtocol is a @runtime_checkable Protocol, so any object with a matching canonicalize method satisfies isinstance(...) without subclassing.
BasePlatformAdapter now provides a default canonicalize() that returns the raw id unchanged (identity function). Any subclass therefore satisfies the protocol without an override — override only to add real reconciliation.
Wiring
Two entry points on BotSessionManager inject a canonicalizer.
| Entry point | Call | When |
|---|---|---|
| Constructor arg | BotSessionManager(..., identity_canonicalizer=...) | Wire at build time. |
| Runtime injection | mgr.attach_gateway_runtime(GatewayRuntimeSeams(identity_canonicalizer=...)) | Wire (or replace) at runtime. |
When both a canonicalizer and an identity_resolver are wired, the canonicalizer always runs first — the resolver sees the stabilised id.
Fail-open contract: any exception or empty result ("", None, or a non-string) falls back to the raw id. The failure is logged at warning level and never breaks session keying.
from praisonaiagents.bots import GatewayRuntimeSeams
from praisonai_bot.bots._session import BotSessionManager
# Constructor
session = BotSessionManager(platform="myplatform", identity_canonicalizer=canon)
# Runtime
session.attach_gateway_runtime(GatewayRuntimeSeams(identity_canonicalizer=canon))Common Patterns
Learn a mapping, then canonicalize
from praisonaiagents.gateway import IdentityCanonicalizerProtocol
class AliasCanonicalizer:
def __init__(self):
self._alias_to_canonical: dict[str, str] = {}
def learn(self, alias: str, canonical: str) -> None:
self._alias_to_canonical[alias] = canonical
def canonicalize(self, platform: str, raw_user_id: str) -> str:
return self._alias_to_canonical.get(raw_user_id, raw_user_id)
canon = AliasCanonicalizer()
canon.learn("alt-123", "user-123")
assert canon.canonicalize("myplatform", "alt-123") == "user-123"
assert canon.canonicalize("myplatform", "unknown") == "unknown" # fail-open
assert isinstance(canon, IdentityCanonicalizerProtocol)Guard against wrong-shape addresses
Only reconcile pairs you positively recognise; return the raw id for anything else so a hostile or malformed address can’t hijack another user’s identity.
class StrictCanonicalizer:
def canonicalize(self, platform: str, raw_user_id: str) -> str:
if platform != "myplatform" or "@" not in raw_user_id:
return raw_user_id
local, _, domain = raw_user_id.partition("@")
if domain != "alt.example": # only this alternate domain maps
return raw_user_id
return f"{local}@canonical.example"Best Practices
Always fail open
Return raw_user_id unchanged whenever you can’t map it. A missing mapping must never corrupt identity — it should be exactly today’s behaviour.
Canonicalize before every key
Reconcile the id first, then derive session, memory, allowlist, and pairing keys from the result. Canonicalizing after a key is derived reintroduces the split.
Accept only exact, known pairings
Validate the shape of both addresses before storing a mapping. Reject group, cross-type, or unexpected-domain pairs so a hostile alternate id can’t map onto a real user.
Keep canonicalize deterministic
The same raw id must always map to the same canonical id within a process. Non-deterministic output splits sessions and breaks allowlist matches.
The adapter is a canonicalizer by default
Because BasePlatformAdapter.canonicalize() exists, hand the adapter itself to identity_canonicalizer= instead of writing a separate class — do this when the alias mapping already lives on the adapter.