feat(modules): 帮助中心、工单、工作流与多渠道接入

补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
WangShen007 committed 2026-10-17 22:05:00 +08:00
1 parent 69a71aabbb
commit e0baca15a9
225 files changed
+59337

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
"""
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.
"""
from app.channels.sms.base import (
SmsProvider, SmsWebhookRequest, CredentialField,
get_provider, list_providers, register_provider,
)
# Import provider modules so they self-register
from app.channels.sms import twilio # noqa: F401
from app.channels.sms import vonage # noqa: F401
from app.channels.sms import messagebird # noqa: F401
from app.channels.sms import plivo # noqa: F401
from app.channels.sms import brevo # noqa: F401
from app.channels.sms import sns # noqa: F401
# The channel adapter (registers itself for channel_type 'sms')
from app.channels.sms import adapter # noqa: F401
__all__ = [
"SmsProvider",
"SmsWebhookRequest",
"CredentialField",
"get_provider",
"list_providers",
"register_provider",
]
+60
View File
@@ -0,0 +1,60 @@
"""
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.
"""
from typing import ClassVar, List, Optional
from app.channels.base import ChannelAdapter, InboundMessage, SendResult
from app.channels.registry import register_adapter
from app.channels.sms.base import MAX_MESSAGE_LENGTH, get_provider
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
from app.core.logger import get_logger
logger = get_logger(__name__)
def account_provider_name(account: ChannelAccount) -> str:
"""The SMS provider chosen for this account (stored non-secret in settings;
defaults to twilio for accounts connected before providers existed)."""
return (account.settings or {}).get("provider", "twilio")
class SmsAdapter(ChannelAdapter):
"""The 'sms' channel. Inbound parsing/verification is provider-specific and
handled by the /webhooks/sms route; this adapter only dispatches outbound
sends to the account's provider."""
channel_type: ClassVar[str] = ChannelType.SMS.value
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
# SMS inbound is verified per-provider inside the webhook route.
return account is not None
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
# Inbound is parsed per-provider in the webhook route, not here.
return []
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
provider = get_provider(account_provider_name(account))
if provider is None:
return SendResult(ok=False, error=f"Unknown SMS provider '{account_provider_name(account)}'")
return await provider.send(account, conversation.external_conversation_id, text)
def format_outbound(self, markdown: str) -> str:
plain = markdown.replace("**", "").replace("__", "")
return plain[:MAX_MESSAGE_LENGTH]
register_adapter(SmsAdapter())
+136
View File
@@ -0,0 +1,136 @@
"""
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, field
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},
)
+74
View File
@@ -0,0 +1,74 @@
"""
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.
"""
from typing import ClassVar, List
from app.channels.base import InboundMessage, SendResult
from app.channels.sms.base import (
CredentialField, SmsProvider, SmsWebhookRequest,
get_http_client, inbound, register_provider,
)
from app.models.channels import ChannelAccount
from app.core.logger import get_logger
logger = get_logger(__name__)
BREVO_SMS_API = "https://api.brevo.com/v3/transactionalSMS/sms"
class BrevoProvider(SmsProvider):
"""Brevo transactional SMS. Outbound only in most plans — inbound replies
depend on Brevo's inbound SMS support; when available they post to the
per-account webhook URL (authenticated by its token, not a signature)."""
name: ClassVar[str] = "brevo"
label: ClassVar[str] = "Brevo"
credential_fields: ClassVar[List[CredentialField]] = [
CredentialField("api_key", "API key", secret=True),
]
def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]:
# Brevo inbound payloads vary; accept the common field names.
p = req.json_body or req.params
sender = p.get("from") or p.get("sender") or p.get("msisdn") or ""
text = (p.get("text") or p.get("message") or p.get("content") or "").strip()
if not sender or not text:
return []
recipient = str(p.get("to") or account.external_account_id)
return [inbound(recipient, sender, text, str(p.get("id") or p.get("messageId") or ""))]
async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult:
creds = self.credentials(account)
try:
response = await get_http_client().post(
BREVO_SMS_API,
headers={"api-key": creds["api_key"], "content-type": "application/json"},
json={"sender": account.external_account_id, "recipient": to, "content": text},
)
data = response.json()
if response.status_code >= 300:
return SendResult(ok=False, error=str(data.get("message") or f"HTTP {response.status_code}"))
return SendResult(ok=True, external_message_id=str(data.get("messageId", "")))
except Exception as e:
logger.error(f"Brevo send failed: {e}")
return SendResult(ok=False, error=str(e))
async def validate_credentials(self, phone_number: str, credentials: dict) -> None:
if not credentials.get("api_key"):
raise ValueError("API key is required")
register_provider(BrevoProvider())
+88
View File
@@ -0,0 +1,88 @@
"""
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 base64
import hashlib
import hmac
from typing import ClassVar, List
from app.channels.base import InboundMessage, SendResult
from app.channels.sms.base import (
CredentialField, SmsProvider, SmsWebhookRequest,
get_http_client, inbound, register_provider,
)
from app.models.channels import ChannelAccount
from app.core.logger import get_logger
logger = get_logger(__name__)
MESSAGEBIRD_API = "https://rest.messagebird.com/messages"
class MessageBirdProvider(SmsProvider):
name: ClassVar[str] = "messagebird"
label: ClassVar[str] = "MessageBird (Bird)"
credential_fields: ClassVar[List[CredentialField]] = [
CredentialField("access_key", "Access key", secret=True),
CredentialField("signing_key", "Signing key (optional)", secret=True, optional=True),
]
async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
signing_key = self.credentials(account).get("signing_key")
if not signing_key:
return True
timestamp = req.headers.get("messagebird-request-timestamp", "")
signature = req.headers.get("messagebird-signature", "")
if not (timestamp and signature):
return False
# v1: base64(HMAC-SHA256(key, "{ts}\n{url}\n{sha256(body)}"))
body_hash = hashlib.sha256(req.raw_body).digest()
parts = timestamp.encode() + b"\n" + req.url.encode() + b"\n" + body_hash
expected = base64.b64encode(hmac.new(signing_key.encode(), parts, hashlib.sha256).digest()).decode()
return hmac.compare_digest(expected, signature)
def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]:
p = req.json_body or req.params
sender = p.get("originator") or p.get("sender") or ""
text = (p.get("payload") or p.get("body") or "").strip()
if not sender or not text:
return []
recipient = str(p.get("recipient") or account.external_account_id)
return [inbound(recipient, sender, text, str(p.get("id", "")))]
async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult:
creds = self.credentials(account)
try:
response = await get_http_client().post(
MESSAGEBIRD_API,
headers={"Authorization": f"AccessKey {creds['access_key']}"},
json={"originator": account.external_account_id, "recipients": [to], "body": text},
)
data = response.json()
if response.status_code >= 300:
errors = data.get("errors") or [{}]
return SendResult(ok=False, error=str(errors[0].get("description") or f"HTTP {response.status_code}"))
return SendResult(ok=True, external_message_id=str(data.get("id", "")))
except Exception as e:
logger.error(f"MessageBird send failed: {e}")
return SendResult(ok=False, error=str(e))
async def validate_credentials(self, phone_number: str, credentials: dict) -> None:
if not credentials.get("access_key"):
raise ValueError("Access key is required")
register_provider(MessageBirdProvider())
+95
View File
@@ -0,0 +1,95 @@
"""
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 base64
import hashlib
import hmac
from typing import ClassVar, List
from app.channels.base import InboundMessage, SendResult
from app.channels.sms.base import (
CredentialField, SmsProvider, SmsWebhookRequest,
get_http_client, inbound, register_provider,
)
from app.models.channels import ChannelAccount
from app.core.logger import get_logger
from app.core.external_urls import external_base
logger = get_logger(__name__)
PLIVO_API_BASE = external_base("PLIVO_API_BASE", "https://api.plivo.com/v1")
class PlivoProvider(SmsProvider):
name: ClassVar[str] = "plivo"
label: ClassVar[str] = "Plivo"
signs_webhook: ClassVar[bool] = True
credential_fields: ClassVar[List[CredentialField]] = [
CredentialField("auth_id", "Auth ID"),
CredentialField("auth_token", "Auth token", secret=True),
]
async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
"""X-Plivo-Signature-V3: base64(HMAC-SHA256(auth_token, url + nonce))."""
auth_token = self.credentials(account).get("auth_token", "")
signature = req.headers.get("x-plivo-signature-v3", "")
nonce = req.headers.get("x-plivo-signature-v3-nonce", "")
if not (auth_token and signature and nonce):
return False
expected = base64.b64encode(
hmac.new(auth_token.encode(), (req.url + nonce).encode(), hashlib.sha256).digest()
).decode()
# Plivo may send multiple comma-separated signatures
return any(hmac.compare_digest(expected, s) for s in signature.split(","))
def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]:
p = req.params
sender, text = p.get("From", ""), (p.get("Text") or "").strip()
if not sender or not text:
return []
return [inbound(p.get("To", ""), sender, text, p.get("MessageUUID", ""))]
async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult:
creds = self.credentials(account)
try:
response = await get_http_client().post(
f"{PLIVO_API_BASE}/Account/{creds['auth_id']}/Message/",
auth=(creds["auth_id"], creds["auth_token"]),
json={"src": account.external_account_id, "dst": to, "text": text},
)
data = response.json()
if response.status_code >= 300:
return SendResult(ok=False, error=str(data.get("error") or f"HTTP {response.status_code}"))
ids = data.get("message_uuid") or []
return SendResult(ok=True, external_message_id=ids[0] if ids else None)
except Exception as e:
logger.error(f"Plivo send failed: {e}")
return SendResult(ok=False, error=str(e))
async def validate_credentials(self, phone_number: str, credentials: dict) -> None:
auth_id, token = credentials.get("auth_id"), credentials.get("auth_token")
if not (auth_id and token):
raise ValueError("Auth ID and auth token are required")
try:
response = await get_http_client().get(
f"{PLIVO_API_BASE}/Account/{auth_id}/", auth=(auth_id, token))
except Exception as e:
raise ValueError(f"Could not reach Plivo: {e}")
if response.status_code >= 300:
raise ValueError("Invalid Plivo Auth ID or auth token")
register_provider(PlivoProvider())
+173
View File
@@ -0,0 +1,173 @@
"""
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 asyncio
import base64
import json
import re
from typing import ClassVar, List, Optional
from urllib.parse import urlparse
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.x509 import load_pem_x509_certificate
from app.channels.base import InboundMessage, SendResult
from app.channels.sms.base import (
CredentialField, SmsProvider, SmsWebhookRequest,
get_http_client, inbound, register_provider,
)
from app.models.channels import ChannelAccount
from app.core.logger import get_logger
logger = get_logger(__name__)
# Signed-string field order per SNS message type
_SIGN_FIELDS = {
"Notification": ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"],
"SubscriptionConfirmation": ["Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"],
"UnsubscribeConfirmation": ["Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"],
}
_cert_cache: dict = {}
def _string_to_sign(body: dict) -> Optional[str]:
fields = _SIGN_FIELDS.get(body.get("Type"))
if not fields:
return None
parts = []
for key in fields:
if key in body and body[key] is not None:
parts.append(f"{key}\n{body[key]}\n")
return "".join(parts)
# Only genuine SNS endpoints — NOT any *.amazonaws.com (e.g. an attacker could
# host a spoofed cert on *.s3.amazonaws.com). AWS SNS uses sns.<region>.amazonaws.com.
_SNS_HOST_RE = re.compile(r"^sns\.[a-z0-9-]+\.amazonaws\.com$")
def _valid_sns_host(url: str) -> bool:
parsed = urlparse(url)
return parsed.scheme == "https" and parsed.hostname is not None and \
bool(_SNS_HOST_RE.match(parsed.hostname))
class SnsProvider(SmsProvider):
"""AWS SNS / End User Messaging. Outbound via SNS Publish; inbound arrives
as SNS HTTPS-subscription notifications (two-way SMS → SNS topic)."""
name: ClassVar[str] = "sns"
label: ClassVar[str] = "AWS SNS"
signs_webhook: ClassVar[bool] = True
credential_fields: ClassVar[List[CredentialField]] = [
CredentialField("aws_access_key_id", "AWS access key ID"),
CredentialField("aws_secret_access_key", "AWS secret access key", secret=True),
CredentialField("region", "AWS region (e.g. us-east-1)"),
CredentialField("topic_arn", "SNS topic ARN (optional)", optional=True),
]
async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
body = req.json_body or {}
configured_topic = self.credentials(account).get("topic_arn")
if configured_topic and body.get("TopicArn") != configured_topic:
return False
cert_url = body.get("SigningCertURL", "")
if not _valid_sns_host(cert_url):
return False
string_to_sign = _string_to_sign(body)
signature = body.get("Signature")
if not string_to_sign or not signature:
return False
try:
cert_pem = await self._fetch_cert(cert_url)
algorithm = hashes.SHA256() if body.get("SignatureVersion") == "2" else hashes.SHA1()
public_key = load_pem_x509_certificate(cert_pem).public_key()
public_key.verify(base64.b64decode(signature), string_to_sign.encode(),
padding.PKCS1v15(), algorithm)
return True
except Exception as e:
logger.error(f"SNS signature verification failed: {e}")
return False
async def handle_control(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
"""Auto-confirm the SNS HTTPS subscription by visiting SubscribeURL."""
body = req.json_body or {}
if body.get("Type") == "SubscriptionConfirmation" and body.get("SubscribeURL"):
# Only fetch AWS-hosted SubscribeURLs — verify_webhook already ran,
# but restrict here too to avoid any SSRF to an attacker URL.
if not _valid_sns_host(body["SubscribeURL"]):
logger.warning(f"Refusing non-SNS SubscribeURL for account {account.id}")
return True
try:
await get_http_client().get(body["SubscribeURL"])
logger.info(f"Confirmed SNS subscription for account {account.id}")
except Exception as e:
logger.error(f"SNS subscription confirmation failed: {e}")
return True
return body.get("Type") == "UnsubscribeConfirmation"
def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]:
body = req.json_body or {}
if body.get("Type") != "Notification":
return []
message = body.get("Message")
try:
sms = json.loads(message) if isinstance(message, str) else (message or {})
except (ValueError, TypeError):
return []
sender = sms.get("originationNumber", "")
text = (sms.get("messageBody") or "").strip()
if not sender or not text:
return []
return [inbound(sms.get("destinationNumber", ""), sender, text, body.get("MessageId", ""))]
async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult:
creds = self.credentials(account)
try:
message_id = await asyncio.to_thread(self._publish, creds, to, text)
return SendResult(ok=True, external_message_id=message_id)
except Exception as e:
logger.error(f"SNS send failed: {e}")
return SendResult(ok=False, error=str(e))
@staticmethod
def _publish(creds: dict, to: str, text: str) -> str:
import boto3
client = boto3.client(
"sns",
aws_access_key_id=creds["aws_access_key_id"],
aws_secret_access_key=creds["aws_secret_access_key"],
region_name=creds.get("region"),
)
result = client.publish(PhoneNumber=to, Message=text)
return result.get("MessageId", "")
async def _fetch_cert(self, url: str) -> bytes:
if url in _cert_cache:
return _cert_cache[url]
response = await get_http_client().get(url)
cert = response.content
_cert_cache[url] = cert
return cert
async def validate_credentials(self, phone_number: str, credentials: dict) -> None:
for key in ("aws_access_key_id", "aws_secret_access_key", "region"):
if not credentials.get(key):
raise ValueError("AWS access key, secret, and region are required")
register_provider(SnsProvider())
+104
View File
@@ -0,0 +1,104 @@
"""
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 base64
import hashlib
import hmac
from typing import ClassVar, List
from app.channels.base import InboundMessage, SendResult
from app.channels.sms.base import (
CredentialField, SmsProvider, SmsWebhookRequest,
get_http_client, inbound, register_provider,
)
from app.models.channels import ChannelAccount
from app.core.logger import get_logger
from app.core.external_urls import external_base
logger = get_logger(__name__)
TWILIO_API_BASE = external_base("TWILIO_API_BASE", "https://api.twilio.com/2010-04-01")
def _verify_signature(auth_token: str, url: str, param_pairs, signature: str) -> bool:
"""X-Twilio-Signature: base64(HMAC-SHA1(auth_token, url + sorted concatenated
key/values)). param_pairs preserves repeated keys.
HMAC-SHA1 is mandated by Twilio's signature spec (not a free choice); HMAC
with SHA1 remains secure for message authentication.
"""
if not auth_token or not signature:
return False
payload = url + "".join(f"{k}{v}" for k, v in sorted(param_pairs, key=lambda kv: kv[0]))
expected = base64.b64encode(
hmac.new(auth_token.encode(), payload.encode(), hashlib.sha1).digest() # noqa: S324 - Twilio protocol
).decode()
return hmac.compare_digest(expected, signature)
class TwilioProvider(SmsProvider):
name: ClassVar[str] = "twilio"
label: ClassVar[str] = "Twilio"
signs_webhook: ClassVar[bool] = True
credential_fields: ClassVar[List[CredentialField]] = [
CredentialField("account_sid", "Account SID"),
CredentialField("auth_token", "Auth token", secret=True),
]
async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
return _verify_signature(
self.credentials(account).get("auth_token", ""),
req.url, req.param_pairs,
req.headers.get("x-twilio-signature", ""),
)
def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]:
p = req.params
sender, body = p.get("From", ""), (p.get("Body") or "").strip()
if not sender or not body:
return []
return [inbound(p.get("To", ""), sender, body, p.get("MessageSid", ""))]
async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult:
creds = self.credentials(account)
try:
response = await get_http_client().post(
f"{TWILIO_API_BASE}/Accounts/{creds['account_sid']}/Messages.json",
auth=(creds["account_sid"], creds["auth_token"]),
data={"From": account.external_account_id, "To": to, "Body": text},
)
data = response.json()
if response.status_code >= 300:
return SendResult(ok=False, error=str(data.get("message") or f"HTTP {response.status_code}"))
return SendResult(ok=True, external_message_id=data.get("sid"))
except Exception as e:
logger.error(f"Twilio send failed: {e}")
return SendResult(ok=False, error=str(e))
async def validate_credentials(self, phone_number: str, credentials: dict) -> None:
sid, token = credentials.get("account_sid"), credentials.get("auth_token")
if not (sid and token):
raise ValueError("Account SID and auth token are required")
try:
response = await get_http_client().get(
f"{TWILIO_API_BASE}/Accounts/{sid}.json", auth=(sid, token))
except Exception as e:
raise ValueError(f"Could not reach Twilio: {e}")
if response.status_code >= 300:
raise ValueError("Invalid Twilio Account SID or auth token")
register_provider(TwilioProvider())
+104
View File
@@ -0,0 +1,104 @@
"""
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 hashlib
import hmac
from typing import ClassVar, List
from app.channels.base import InboundMessage, SendResult
from app.channels.sms.base import (
CredentialField, SmsProvider, SmsWebhookRequest,
get_http_client, inbound, register_provider,
)
from app.models.channels import ChannelAccount
from app.core.logger import get_logger
logger = get_logger(__name__)
VONAGE_SMS_URL = "https://rest.nexmo.com/sms/json"
def _verify_hmac_signature(params: dict, signature_secret: str) -> bool:
"""Vonage HMAC-SHA256 signed-webhook check: HMAC-SHA256(secret) over the
sorted params (excluding `sig`, with `&`/`=` in values replaced by `_`).
Configure the Vonage account's signature method as "SHA-256 HMAC". The
legacy MD5 method is intentionally not supported (weak hash).
"""
provided = params.get("sig", "")
if not provided:
return False
parts = []
for key in sorted(k for k in params if k != "sig"):
value = str(params[key]).replace("&", "_").replace("=", "_")
parts.append(f"&{key}={value}")
digest = hmac.new(
signature_secret.encode(), "".join(parts).encode(), hashlib.sha256
).hexdigest().upper()
return hmac.compare_digest(digest, provided.upper())
class VonageProvider(SmsProvider):
name: ClassVar[str] = "vonage"
label: ClassVar[str] = "Vonage (Nexmo)"
credential_fields: ClassVar[List[CredentialField]] = [
CredentialField("api_key", "API key"),
CredentialField("api_secret", "API secret", secret=True),
CredentialField("signature_secret", "Signature secret (optional)", secret=True, optional=True),
]
async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
secret = self.credentials(account).get("signature_secret")
if not secret:
# No signature configured - the route authenticates this account with the
# per-account webhook token instead (signs_webhook is False).
return True
source = req.json_body or req.params
return _verify_hmac_signature(source, secret)
def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]:
p = req.json_body or req.params
sender = p.get("msisdn", "")
text = (p.get("text") or "").strip()
if not sender or not text:
return []
return [inbound(p.get("to", ""), sender, text, p.get("messageId", ""))]
async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult:
creds = self.credentials(account)
try:
response = await get_http_client().post(VONAGE_SMS_URL, data={
"api_key": creds["api_key"],
"api_secret": creds["api_secret"],
"from": account.external_account_id,
"to": to,
"text": text,
})
data = response.json()
message = (data.get("messages") or [{}])[0]
if message.get("status") != "0":
return SendResult(ok=False, error=str(message.get("error-text") or "Vonage send failed"))
return SendResult(ok=True, external_message_id=message.get("message-id"))
except Exception as e:
logger.error(f"Vonage send failed: {e}")
return SendResult(ok=False, error=str(e))
async def validate_credentials(self, phone_number: str, credentials: dict) -> None:
if not (credentials.get("api_key") and credentials.get("api_secret")):
raise ValueError("API key and API secret are required")
register_provider(VonageProvider())