Files
replypier/backend/app/utils/phone.py
WangShen007 9eb8613c05 feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
2026-09-23 22:05:00 +08:00

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("+")