WhatsApp User Mapping¶
Related Documentation:
- WhatsApp Chat Integration - End-to-end flow
- WhatsApp Conversation Use Case - Message processing
- ADR-003: Database Strategy - Data storage patterns
- Development Practices - Implementation guidelines
This document describes the WhatsApp user mapping model and related operations in the OmniButler system.
Overview¶
The WhatsApp user mapping system connects a user's WhatsApp number to their OmniButler account, enabling secure WhatsApp-based interactions with financial data.
Data Model¶
WhatsAppMappingState¶
class WhatsAppMappingState(StrEnum):
"""
Possible states for a WhatsApp user mapping.
States:
PENDING: Initial state when a WhatsApp number is registered but not linked to a user
LINKING: A token has been generated and sent to the user
ACTIVE: Successfully linked to a user account
DISABLED: Temporarily disabled
BLOCKED: Permanently blocked
"""
PENDING = "PENDING"
LINKING = "LINKING"
ACTIVE = "ACTIVE"
DISABLED = "DISABLED"
BLOCKED = "BLOCKED"
WhatsAppUserMapping¶
class WhatsAppUserMapping(BaseModel):
"""
Represents a mapping between a WhatsApp number and a user account.
This model tracks:
1. The relationship between WhatsApp numbers and application users
2. The linking process including token generation and validation
3. The state history and message status
Attributes:
id: Unique identifier for this mapping
whatsapp_number: The WhatsApp number with whatsapp: prefix
app_user_id: User ID, set when linked
state: Current state of the mapping
previous_state: Previous state before the last change
state_changed_at: When the state was last changed
created_at: When this mapping was initially created
last_used_at: When this mapping was last used for messaging
linking_token: Token generated for account linking (if any)
token_expires_at: When the linking token expires
token_used: Whether the linking token has been used
welcome_message_sent: Whether the welcome message has been sent
context: Additional context for the conversation
"""
id: uuid.UUID | None = None
whatsapp_number: str
app_user_id: uuid.UUID | None = None
state: WhatsAppMappingState = WhatsAppMappingState.PENDING
previous_state: WhatsAppMappingState | None = None
state_changed_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
last_used_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
linking_token: str | None = None
token_expires_at: datetime | None = None
token_used: bool = False
welcome_message_sent: bool = False
context: dict[str, Any] = Field(default_factory=dict)
State Transitions¶
The WhatsApp user mapping follows a state machine pattern:
┌─────────┐ ┌─────────┐ ┌────────┐
│ PENDING │ ──────▶ │ LINKING │ ──────▶ │ ACTIVE │
└─────────┘ └─────────┘ └────────┘
│ │
│ ┌────────┐ │
└──────────────────▶ │DISABLED│ ◀────────┘
└────────┘
│
┌────────┐
│ BLOCKED│
└────────┘
- PENDING: Initial state when a WhatsApp number is registered
- LINKING: Token has been generated and user is in the process of linking
- ACTIVE: Successfully linked to user account and ready for interaction
- DISABLED: Temporarily disabled, can be reactivated
- BLOCKED: Permanently blocked due to abuse or at user request
Repository Pattern¶
The WhatsApp user mapping follows the repository pattern:
from domain.repositories.whatsapp_user_mapping_repository import WhatsAppUserMappingRepository
# Example usage
repository = get_whatsapp_repository()
mapping = repository.get_by_whatsapp_number("whatsapp:+1234567890")
if mapping:
# Update state with tracking
repository.update_state(mapping.id, WhatsAppMappingState.ACTIVE)
Firestore Implementation¶
The WhatsApp user mapping is stored in Firestore:
# Good: Clear collection naming and structure
whatsapp_mappings_collection = db.collection("whatsappUserMappings")
mapping_doc = whatsapp_mappings_collection.document(str(mapping_id))
# Good: Repository pattern with error handling
class FirestoreWhatsAppUserMappingRepository(WhatsAppUserMappingRepository):
def __init__(self):
"""Initialize repository with Firestore collection reference."""
db = firestore.client(app=app)
self.db = db
self.collection = db.collection("whatsappUserMappings")
self.tokens_collection = db.collection("whatsappLinkingTokens")
def update_state(self, id: uuid.UUID, state: WhatsAppMappingState) -> None:
"""
Update the state of a mapping.
Args:
id: The ID of the mapping to update
state: The new state
"""
doc = self.collection.document(str(id)).get()
if not doc.exists:
logger.warning(f"Cannot update state for non-existent mapping: {id}")
return
data = doc.to_dict()
current_state = data.get("state")
# Only update if state is changing
if current_state != state:
self.collection.document(str(id)).update({
"state": state,
"previous_state": current_state,
"state_changed_at": datetime.now(UTC),
"last_used_at": datetime.now(UTC)
})
Linking Process¶
The linking process connects a WhatsApp number to a user account:
- User initiates linking via app or WhatsApp message
- System generates secure token and sets state to LINKING
- User clicks link in WhatsApp message, authenticates
- System verifies token and updates mapping with user ID
- State changes to ACTIVE and welcome message is sent
Token Management¶
Tokens for WhatsApp linking are stored in a separate Firestore collection:
def create_linking_token(self, whatsapp_number: str) -> str:
"""
Create a token for linking WhatsApp to Firebase authenticated account.
Args:
whatsapp_number: The WhatsApp number to link
Returns:
A secure token string
"""
mapping = self.get_by_whatsapp_number(whatsapp_number)
if not mapping:
logger.error(f"Cannot create token for unknown number: {whatsapp_number}")
return None
# Generate secure token
token = secrets.token_urlsafe(32)
expires_at = datetime.now(UTC) + timedelta(hours=24)
# Store token in a separate collection
self.tokens_collection.document(token).set({
"whatsapp_number": whatsapp_number,
"mapping_id": str(mapping.id),
"expires_at": expires_at,
"used": False,
"created_at": datetime.now(UTC),
})
return token
Watcher Implementation¶
The system watches for state changes and sends welcome messages:
def watch_whatsapp_user_mappings():
"""
Watch for changes to WhatsApp user mappings and process state transitions.
This watcher:
1. Detects when mappings change state to ACTIVE
2. Sends welcome messages to newly activated users
3. Updates the mapping to track message delivery status
Returns:
Firestore watch listener that can be unsubscribed
"""
# Implementation details...
Error Handling¶
try:
# Verify the WhatsApp linking token
whatsapp_number = self.user_mapping_repository.verify_linking_token(request.token)
if not whatsapp_number:
logger.warning(f"Invalid or expired WhatsApp linking token: {request.token[:10]}...")
return WhatsAppLinkResponse(
status="error",
message="Invalid or expired WhatsApp linking token",
)
except Exception as e:
logger.error(f"Error linking WhatsApp to user: {e!s}", exc_info=True)
return WhatsAppLinkResponse(
status="error",
message="An unexpected error occurred",
)
Security Considerations¶
- Tokens are securely generated using
secrets.token_urlsafe(32) - Tokens expire after 24 hours
- Tokens can only be used once
- State transitions are tracked for audit purposes
- Token verification prevents unauthorized linking
- WhatsApp number validation occurs at the application config level
Validation Notes¶
WhatsApp phone number validation is handled at the application config level rather than in the entity model:
def validate_twilio_from_number(cls, value: str) -> str:
"""Validate and normalize Twilio phone number format."""
if not value:
raise ValueError("Twilio phone number is required")
# Handle WhatsApp format: ensure it starts with whatsapp:+
if "whatsapp" in value.lower():
# Strip any existing format and rebuild
stripped = re.sub(r'[^\d]', '', value)
return f"whatsapp:+{stripped}"
# Handle regular phone numbers
# ...
This approach ensures consistent validation across the application while keeping entities focused on data structure.