Files
replypier/backend/app/channels/sms/base.py
T

137 lines
4.9 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 json
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import ClassVar, Dict, List, Optional
import httpx
from app.channels.base import InboundMessage, SendResult
from app.core.security import decrypt_api_key
from app.models.channels import ChannelAccount
from app.core.logger import get_logger
logger = get_logger(__name__)
REQUEST_TIMEOUT_SECONDS = 15.0
# Carriers segment long messages; keep replies to a sane cap
MAX_MESSAGE_LENGTH = 1600
_http_client: Optional[httpx.AsyncClient] = None
def get_http_client() -> httpx.AsyncClient:
global _http_client
if _http_client is None or _http_client.is_closed:
_http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
return _http_client
@dataclass
class SmsWebhookRequest:
"""Everything a provider needs to verify and parse an inbound webhook,
normalized by the /webhooks/sms route so providers stay transport-agnostic."""
headers: dict
params: dict # decoded form params (repeated keys → last)
param_pairs: List[tuple] # form params preserving order/duplicates
json_body: Optional[dict] # parsed JSON body (JSON providers)
raw_body: bytes
url: str # the public webhook URL (for signatures)
@dataclass
class CredentialField:
key: str
label: str
secret: bool = False
optional: bool = False
class SmsProvider(ABC):
"""A pluggable SMS backend (Twilio, Vonage, ...). One SmsProvider per
vendor; all share the single 'sms' channel so conversations, badges and
agent routing stay uniform."""
name: ClassVar[str]
label: ClassVar[str]
# Credential fields the connect UI collects (besides the phone number)
credential_fields: ClassVar[List[CredentialField]] = []
# True when the provider signs inbound webhooks and we need the raw body
signs_webhook: ClassVar[bool] = False
async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
"""Authenticate an inbound webhook. Default: accept - providers that leave
signs_webhook False have no signature to check, and the route authenticates
them with the per-account URL token instead."""
return True
@abstractmethod
def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]:
"""Normalize an inbound SMS into InboundMessage(s)."""
async def handle_control(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
"""Handle non-message control callbacks (e.g. SNS subscription
confirmation). Return True if fully handled — the route then stops."""
return False
@abstractmethod
async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult:
"""Send an SMS from the account's number to `to`."""
async def validate_credentials(self, phone_number: str, credentials: dict) -> None:
"""Optionally verify credentials at connect time. Raise ValueError with
a user-facing message on failure. Default: no check."""
return None
@staticmethod
def credentials(account: ChannelAccount) -> dict:
return json.loads(decrypt_api_key(account.encrypted_credentials))
_providers: Dict[str, SmsProvider] = {}
def register_provider(provider: SmsProvider) -> SmsProvider:
_providers[provider.name] = provider
return provider
def get_provider(name: str) -> Optional[SmsProvider]:
return _providers.get(name)
def list_providers() -> List[SmsProvider]:
return list(_providers.values())
def inbound(to: str, sender: str, text: str, message_id: str) -> InboundMessage:
"""Build a normalized InboundMessage for an SMS (conversation keyed by the
customer's number). external_message_id is left empty when the provider
gives none — the webhook skips dedupe rather than risk collapsing two
legitimate identical replies (e.g. "Yes", "Yes") into one."""
return InboundMessage(
external_account_id=to,
external_conversation_id=sender,
external_user_id=sender,
external_message_id=message_id or "",
text=text,
# The sender *is* the customer's number; providers vary on the leading
# '+' (Twilio sends it, Vonage doesn't) — normalize_phone accepts both.
profile={"phone": sender},
)