Skip to content

WhatsApp User Mapping

Related Documentation:

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:

  1. User initiates linking via app or WhatsApp message
  2. System generates secure token and sets state to LINKING
  3. User clicks link in WhatsApp message, authenticates
  4. System verifies token and updates mapping with user ID
  5. 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.