feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
ba91abc4b2
commit
83a16ed07d
225 files changed
+59337
No files matched your search
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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())
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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},
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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())
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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())
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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())
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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())
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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())
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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())
|
||||
Reference in new issue
Block a user