"""
SMS keyword detection and phone normalization utilities.
Used by the SMS connector webhook handlers to detect opt-out commands,
affirmative responses, and call-back requests from inbound SMS replies.
"""
import re
import logging
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Keyword definitions
# ---------------------------------------------------------------------------
# Order matters: check compound keywords before single-word ones.
KEYWORD_PATTERNS = {
# Compound keywords (must be checked before single-word matches)
'CALL_ME': [r'(?i)\bcall\s*me\b'],
# Single-word keywords
'STOP': [r'(?i)\b(?:stop|unsubscribe|opt[- ]?out|cancel)\b'],
'YES': [r'(?i)\b(?:yes|yeah|sure|ok|okay|yep)\b'],
}
# All recognized keywords
KNOWN_KEYWORDS = {'STOP', 'YES', 'CALL_ME'}
# ---------------------------------------------------------------------------
# Keyword detection
# ---------------------------------------------------------------------------
def detect_keyword(body: str) -> str:
"""
Detect SMS keywords in message body.
Returns keyword string (e.g., 'STOP', 'YES', 'CALL_ME') or '' if none found.
Priority order: STOP > CALL_ME > YES
(Opt-out always takes precedence over other actions)
"""
if not body or not isinstance(body, str):
return ''
body_stripped = body.strip()
if not body_stripped:
return ''
# Check STOP first (highest priority — TCPA compliance)
if _match_keyword(body_stripped, 'STOP'):
return 'STOP'
# Check CALL_ME next
if _match_keyword(body_stripped, 'CALL_ME'):
return 'CALL_ME'
# Check YES last
if _match_keyword(body_stripped, 'YES'):
return 'YES'
return ''
def _match_keyword(text: str, keyword: str) -> bool:
"""Check if text matches any pattern for the given keyword."""
patterns = KEYWORD_PATTERNS.get(keyword, [])
for pattern in patterns:
if re.search(pattern, text):
return True
return False
# ---------------------------------------------------------------------------
# Phone normalization
# ---------------------------------------------------------------------------
def normalize_phone(phone: str) -> str:
"""
Normalize phone number to E.164 format (country code + digits).
Handles:
- (555) 123-4567
- 555-123-4567
- 555.123.4567
- 5551234567
- +15551234567
- 1-555-123-4567
Returns: normalized phone string (e.g., '+15551234567')
"""
if not phone:
return ''
# Strip all non-digit characters except leading +
digits = re.sub(r'[^\d]', '', phone)
# Handle leading country code
if digits.startswith('1') and len(digits) == 11:
return f'+{digits}'
elif len(digits) == 10:
return f'+1{digits}'
elif len(digits) == 11 and not digits.startswith('1'):
return f'+{digits}'
elif len(digits) > 11:
# Keep as-is if already E.164-ish
return f'+{digits}' if not phone.startswith('+') else phone
# Fallback: return stripped digits
return digits
def phones_match(phone1: str, phone2: str) -> bool:
"""Compare two phone numbers after normalization."""
return normalize_phone(phone1) == normalize_phone(phone2)