74 lines
3.2 KiB
Python
74 lines
3.2 KiB
Python
"""
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
import re
|
|
|
|
# E.164: '+', a non-zero country code digit, then 7-14 more digits. Everything
|
|
# that identifies a person by phone stores exactly this shape, because
|
|
# customers.phone is a unique identity key — two spellings of one number would
|
|
# be two "people". re.ASCII because \d otherwise matches every Unicode decimal
|
|
# digit (Arabic-Indic and friends), which would let an undialable, unmatchable
|
|
# string become an identity key. Deliberately not libphonenumber:
|
|
# country-specific length rules aren't worth a dependency when Meta/the SMS
|
|
# provider is the real validator and rejects undialable numbers anyway.
|
|
_E164 = re.compile(r"^\+[1-9]\d{7,14}$", re.ASCII)
|
|
|
|
# What people paste: spaces, dashes, dots, parentheses.
|
|
_DECORATION = re.compile(r"[\s\-().]")
|
|
|
|
|
|
def normalize_phone(value: str | None) -> str | None:
|
|
"""The canonical E.164 form of a human-supplied phone number, or None.
|
|
|
|
Strict: the '+' country prefix is required (decoration — spaces, dashes,
|
|
dots, parentheses — is tolerated). Bare digits are rejected here because a
|
|
person typing "1234567890" means a national number, and prepending '+'
|
|
would silently reassign it to whatever country owns that prefix. An
|
|
identity key corrupted by a guess is worse than a rejected form field.
|
|
|
|
Non-strings return None rather than raising: values arrive from AI
|
|
extraction and form payloads, which can hand over lists or numbers.
|
|
"""
|
|
if not value or not isinstance(value, str):
|
|
return None
|
|
candidate = _DECORATION.sub("", value.strip())
|
|
return candidate if _E164.fullmatch(candidate) else None
|
|
|
|
|
|
def normalize_msisdn(value: str | None) -> str | None:
|
|
"""The canonical E.164 form of a platform-supplied subscriber number.
|
|
|
|
Lenient about the prefix in the two shapes platforms actually send:
|
|
bare digits (a WhatsApp wa_id, a Vonage msisdn — E.164 without the '+')
|
|
and the ITU '00' international form some SMS gateways use. Both are
|
|
trustworthy in a way human-typed digits are not. Delegates to
|
|
normalize_phone so there is exactly one definition of the canonical
|
|
shape — the two paths must meet in one column.
|
|
"""
|
|
if not value or not isinstance(value, str):
|
|
return None
|
|
candidate = _DECORATION.sub("", value.strip())
|
|
if candidate.startswith("00") and candidate[2:].isascii() and candidate[2:].isdigit():
|
|
candidate = f"+{candidate[2:]}"
|
|
elif not candidate.startswith("+") and candidate.isascii() and candidate.isdigit():
|
|
candidate = f"+{candidate}"
|
|
return normalize_phone(candidate)
|
|
|
|
|
|
def to_wa_id(phone: str) -> str:
|
|
"""Meta addresses WhatsApp users by E.164 without the '+'."""
|
|
return phone.lstrip("+")
|