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,30 @@
|
||||
"""
|
||||
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.base import (
|
||||
ChannelAdapter, InboundMessage, SendResult, WindowStatus, ChannelInteraction,
|
||||
)
|
||||
from app.channels.registry import get_adapter, register_adapter
|
||||
|
||||
__all__ = [
|
||||
"ChannelAdapter",
|
||||
"InboundMessage",
|
||||
"SendResult",
|
||||
"WindowStatus",
|
||||
"ChannelInteraction",
|
||||
"get_adapter",
|
||||
"register_adapter",
|
||||
]
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Extension points on the channel pipeline.
|
||||
|
||||
An extension can see every channel message in and out and every delivery
|
||||
receipt (event listeners), and can claim an inbound message before the AI
|
||||
answers it (the inbound handler). Nothing here knows who the extension is:
|
||||
load_extensions imports whatever is installed, and the extension registers
|
||||
itself. Without one, every hook is a no-op.
|
||||
|
||||
Hooks receive plain ids and values, never ORM objects or credentials.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import enum
|
||||
import importlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Awaitable, Callable, List, Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.channels.base import InboundMessage, InteractiveMessage, StatusEvent
|
||||
from app.channels.constants import MessageSource
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# The installed extension's hook module, imported (if present) by
|
||||
# load_extensions. Importing it registers its hooks.
|
||||
EXTENSION_HOOKS_MODULE = "app.enterprise.hooks"
|
||||
_EXTENSION_PACKAGE = EXTENSION_HOOKS_MODULE.rsplit(".", 1)[0]
|
||||
|
||||
# Listeners run inline on the message, delivery and receipt paths, so each
|
||||
# gets this long; they are meant to enqueue work, not do it.
|
||||
LISTENER_TIMEOUT_SECONDS = 1.0
|
||||
|
||||
|
||||
class EventKind(str, enum.Enum):
|
||||
INBOUND = "inbound"
|
||||
STATUS = "status"
|
||||
OUTBOUND = "outbound"
|
||||
|
||||
|
||||
class HandledBy(str, enum.Enum):
|
||||
APP = "app"
|
||||
AI = "ai"
|
||||
HUMAN = "human"
|
||||
|
||||
|
||||
@dataclass
|
||||
class InboundContext:
|
||||
"""An inbound message an extension may claim. `event_id` is the id of the
|
||||
`inbound` event that follows for the same message, so a synchronous call
|
||||
and that event can be told apart from two different messages."""
|
||||
event_id: str
|
||||
organization_id: UUID
|
||||
channel_account_id: UUID
|
||||
channel_type: str
|
||||
session_id: str
|
||||
customer_id: str
|
||||
inbound: InboundMessage
|
||||
received_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutboundRecord:
|
||||
"""A message the business sent on a channel."""
|
||||
message_id: Optional[int]
|
||||
external_message_id: Optional[str]
|
||||
source: MessageSource
|
||||
text: str
|
||||
interactive: Optional[InteractiveMessage] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelEvent:
|
||||
"""Something that happened on a channel conversation. `event_id` is
|
||||
deterministic, so a retried webhook or send yields the same id."""
|
||||
kind: EventKind
|
||||
event_id: str
|
||||
organization_id: UUID
|
||||
channel_account_id: UUID
|
||||
occurred_at: datetime
|
||||
session_id: Optional[str] = None
|
||||
customer_id: Optional[str] = None
|
||||
inbound: Optional[InboundMessage] = None
|
||||
handled_by: Optional[HandledBy] = None
|
||||
status: Optional[StatusEvent] = None
|
||||
outbound: Optional[OutboundRecord] = None
|
||||
|
||||
|
||||
InboundHandler = Callable[[InboundContext], Awaitable[bool]]
|
||||
EventListener = Callable[[ChannelEvent], Awaitable[None]]
|
||||
|
||||
_inbound_handler: Optional[InboundHandler] = None
|
||||
_event_listeners: List[EventListener] = []
|
||||
_extensions_loaded = False
|
||||
|
||||
|
||||
def register_inbound_handler(handler: InboundHandler) -> None:
|
||||
"""Let an extension claim inbound messages first. One slot: the handler
|
||||
returns True (exactly) when it has dealt with the message and the AI
|
||||
must not.
|
||||
|
||||
The contract a handler has to live with:
|
||||
- it is cancelled after app_first_routing.APP_HANDLER_TIMEOUT_SECONDS
|
||||
and the AI answers instead, so its own outbound calls must time out
|
||||
sooner — or the customer can get both answers;
|
||||
- it must only do async I/O (blocking work can't be cancelled);
|
||||
- it can see the same message twice (webhook redelivery when dedupe is
|
||||
unavailable) — dedupe on InboundContext.event_id;
|
||||
- it is not the durable record: every inbound message is also announced
|
||||
as an `inbound` event (same event_id), whatever the handler did."""
|
||||
global _inbound_handler
|
||||
_inbound_handler = handler
|
||||
|
||||
|
||||
def register_event_listener(listener: EventListener) -> None:
|
||||
if listener not in _event_listeners:
|
||||
_event_listeners.append(listener)
|
||||
|
||||
|
||||
def inbound_handler() -> Optional[InboundHandler]:
|
||||
return _inbound_handler
|
||||
|
||||
|
||||
async def emit_channel_event(event: ChannelEvent) -> None:
|
||||
"""Tell every listener. A failing or slow listener is logged and skipped —
|
||||
an extension can never break or stall message handling."""
|
||||
for listener in list(_event_listeners):
|
||||
try:
|
||||
await asyncio.wait_for(listener(event), LISTENER_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Channel event listener timed out on {event.kind.value} {event.event_id}")
|
||||
except Exception:
|
||||
logger.exception(f"Channel event listener failed on {event.kind.value} {event.event_id}")
|
||||
|
||||
|
||||
def load_extensions() -> None:
|
||||
"""Import the installed extension's hooks, once. Called at startup by the
|
||||
API and by every worker that sends on channels — explicitly, so a process
|
||||
that never calls it (a migration, a script) never loads an extension."""
|
||||
global _extensions_loaded
|
||||
if _extensions_loaded:
|
||||
return
|
||||
_extensions_loaded = True
|
||||
try:
|
||||
importlib.import_module(EXTENSION_HOOKS_MODULE)
|
||||
except ModuleNotFoundError as e:
|
||||
if e.name not in (EXTENSION_HOOKS_MODULE, _EXTENSION_PACKAGE):
|
||||
logger.exception("Channel extension failed to load") # installed but broken
|
||||
return
|
||||
logger.debug("No channel extension installed")
|
||||
except Exception:
|
||||
logger.exception("Channel extension failed to load")
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Forget every registration (tests)."""
|
||||
global _inbound_handler, _extensions_loaded
|
||||
_inbound_handler = None
|
||||
_event_listeners.clear()
|
||||
_extensions_loaded = False
|
||||
|
||||
|
||||
def inbound_event_id(channel_account_id, external_message_id: str) -> str:
|
||||
return f"{channel_account_id}:{external_message_id}"
|
||||
|
||||
|
||||
def status_event_id(channel_account_id, external_message_id: str, status: str) -> str:
|
||||
return f"{channel_account_id}:{external_message_id}:{status}"
|
||||
|
||||
|
||||
def outbound_event(*, organization_id, channel_account_id, session_id: Optional[str],
|
||||
customer_id: Optional[str], record: OutboundRecord) -> ChannelEvent:
|
||||
# Keyed by the platform's id when there is one, else the stored row; a
|
||||
# send with neither is unrepeatable anyway.
|
||||
key = record.external_message_id or (f"row{record.message_id}" if record.message_id else None)
|
||||
return ChannelEvent(
|
||||
kind=EventKind.OUTBOUND,
|
||||
event_id=f"{channel_account_id}:out:{key}" if key else str(uuid4()),
|
||||
organization_id=organization_id,
|
||||
channel_account_id=channel_account_id,
|
||||
occurred_at=datetime.now(timezone.utc),
|
||||
session_id=session_id,
|
||||
customer_id=customer_id,
|
||||
outbound=record,
|
||||
)
|
||||
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
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 enum
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
from app.channels.interactive_limits import InteractiveLimits
|
||||
from app.models.channels import ChannelAccount, ChannelConversation
|
||||
|
||||
|
||||
class InteractiveReplyType(str, enum.Enum):
|
||||
BUTTON_REPLY = "button_reply" # tap on a reply button we sent
|
||||
LIST_REPLY = "list_reply" # pick from a list message we sent
|
||||
TEMPLATE_BUTTON = "template_button" # tap on an approved template's quick-reply button
|
||||
|
||||
|
||||
@dataclass
|
||||
class InteractiveReply:
|
||||
"""The customer tapped a button or list row. `id` is the payload we set
|
||||
when sending; `title` is the text they saw."""
|
||||
type: InteractiveReplyType
|
||||
id: str
|
||||
title: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SharedLocation:
|
||||
latitude: float
|
||||
longitude: float
|
||||
name: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class InboundMessage:
|
||||
"""A customer message normalized from a channel webhook payload."""
|
||||
external_account_id: str # which connected account received it
|
||||
external_conversation_id: str # platform conversation id (chat_id / wa_id / PSID ...)
|
||||
external_user_id: str # platform id of the customer
|
||||
external_message_id: str # platform message id, used for dedupe
|
||||
text: Optional[str] = None
|
||||
attachments: List[dict] = field(default_factory=list)
|
||||
profile: dict = field(default_factory=dict) # name / phone / username when available
|
||||
timestamp: Optional[datetime] = None
|
||||
interactive: Optional[InteractiveReply] = None
|
||||
location: Optional[SharedLocation] = None
|
||||
# The platform message this one replies to (WhatsApp context.id) — ties a
|
||||
# button tap back to the message that offered the button.
|
||||
reply_to_message_id: Optional[str] = None
|
||||
|
||||
|
||||
class InteractiveKind(str, enum.Enum):
|
||||
BUTTONS = "buttons"
|
||||
LIST = "list"
|
||||
CTA_URL = "cta_url"
|
||||
|
||||
|
||||
@dataclass
|
||||
class InteractiveButton:
|
||||
id: str
|
||||
title: str
|
||||
# The choice's full text when the title had to be cut to fit (not sent)
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class InteractiveRow:
|
||||
id: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class InteractiveSection:
|
||||
rows: List[InteractiveRow]
|
||||
title: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class InteractiveMessage:
|
||||
"""A channel-native choice prompt: reply buttons, a list, or a link button.
|
||||
Limits are enforced by app.channels.interactive.validate_interactive."""
|
||||
kind: InteractiveKind
|
||||
body: str
|
||||
header: Optional[str] = None
|
||||
footer: Optional[str] = None
|
||||
buttons: List[InteractiveButton] = field(default_factory=list)
|
||||
list_button_label: Optional[str] = None
|
||||
sections: List[InteractiveSection] = field(default_factory=list)
|
||||
url: Optional[str] = None
|
||||
display_text: Optional[str] = None
|
||||
|
||||
|
||||
class DeliveryState(str, enum.Enum):
|
||||
SENT = "sent"
|
||||
DELIVERED = "delivered"
|
||||
READ = "read"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatusEvent:
|
||||
"""A delivery receipt for a message we sent."""
|
||||
external_account_id: str
|
||||
external_message_id: str
|
||||
recipient_id: str
|
||||
status: DeliveryState
|
||||
timestamp: Optional[datetime] = None
|
||||
error: Optional[str] = None
|
||||
error_code: Optional[str] = None # the platform's code for `error`
|
||||
pricing_category: Optional[str] = None # e.g. marketing, utility, service
|
||||
# Per-message pricing: whether this message is charged ("regular") or
|
||||
# free ("free_customer_service", "free_entry_point"); `billable` is the
|
||||
# older flag the platform is retiring in its favour
|
||||
pricing_type: Optional[str] = None
|
||||
pricing_model: Optional[str] = None
|
||||
billable: Optional[bool] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SendResult:
|
||||
ok: bool
|
||||
external_message_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
# True when the failure is a closed messaging window that an approved
|
||||
# template message could reopen (WhatsApp)
|
||||
can_template: bool = False
|
||||
# True when the platform rejected the account's credentials (expired or
|
||||
# revoked token): nothing will send until the account is reconnected
|
||||
auth_failed: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelInteraction:
|
||||
"""A non-message interaction from a channel (a shared contact) — distinct
|
||||
from a customer text turn, so it bypasses the AI pipeline and is handled
|
||||
directly."""
|
||||
type: str # 'contact'
|
||||
external_account_id: str
|
||||
external_conversation_id: str
|
||||
external_user_id: str
|
||||
phone: Optional[str] = None # for type == 'contact'
|
||||
profile: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class WindowStatus(str, enum.Enum):
|
||||
OK = "ok"
|
||||
# Outside the customer-service window but a template message can reopen it
|
||||
TEMPLATE_REQUIRED = "template_required"
|
||||
# Outside the window with no recovery path
|
||||
UNDELIVERABLE = "undeliverable"
|
||||
|
||||
|
||||
class ChannelAdapter(ABC):
|
||||
"""Per-channel behavior behind a uniform interface.
|
||||
|
||||
Adapters are stateless singletons: every method receives the
|
||||
ChannelAccount (with credentials accessed via the account repository)
|
||||
and, where relevant, the ChannelConversation.
|
||||
"""
|
||||
|
||||
channel_type: ClassVar[str]
|
||||
# What send_interactive can carry on this channel; None when it can't
|
||||
# send choices at all
|
||||
interactive_limits: ClassVar[Optional[InteractiveLimits]] = None
|
||||
# Whether the platform sends delivery receipts (parse_status_events) for
|
||||
# the messages we send, so their ids are worth recording
|
||||
reports_delivery_status: ClassVar[bool] = False
|
||||
# The platform's cap on one text message, when it has one
|
||||
max_text_length: ClassVar[Optional[int]] = None
|
||||
|
||||
@property
|
||||
def supports_interactive(self) -> bool:
|
||||
return self.interactive_limits is not None
|
||||
|
||||
def choice_limits_for(self, conversation: ChannelConversation) -> Optional[InteractiveLimits]:
|
||||
"""What choices can be offered in this conversation; None where a tap
|
||||
couldn't come back to us. Default: the channel's limits."""
|
||||
return self.interactive_limits
|
||||
|
||||
@abstractmethod
|
||||
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
|
||||
"""Authenticate an inbound webhook request before parsing it."""
|
||||
|
||||
@abstractmethod
|
||||
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||||
"""Extract customer messages from a webhook payload.
|
||||
Non-message events (delivery receipts, edits, joins) yield []."""
|
||||
|
||||
@abstractmethod
|
||||
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||||
"""Deliver a text reply to the customer."""
|
||||
|
||||
async def send_media(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
url: str, content_type: str) -> SendResult:
|
||||
"""Deliver a media attachment. Default: unsupported."""
|
||||
return SendResult(ok=False, error=f"{self.channel_type} does not support media yet")
|
||||
|
||||
async def send_interactive(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
message: InteractiveMessage) -> SendResult:
|
||||
"""Deliver buttons / a list / a link button. Default: unsupported —
|
||||
callers check supports_interactive and fall back to text."""
|
||||
return SendResult(ok=False, error=f"{self.channel_type} does not support interactive messages")
|
||||
|
||||
def parse_status_events(self, payload: dict) -> List[StatusEvent]:
|
||||
"""Extract delivery receipts (sent/delivered/read/failed) from a
|
||||
webhook payload. Default: the channel reports none."""
|
||||
return []
|
||||
|
||||
def check_delivery_window(self, conversation: ChannelConversation) -> WindowStatus:
|
||||
"""Whether the platform still accepts free-form outbound messages.
|
||||
Default: no window restrictions (Telegram, Slack, email...)."""
|
||||
return WindowStatus.OK
|
||||
|
||||
def format_outbound(self, markdown: str) -> str:
|
||||
"""Convert the agent's markdown to the channel's markup and apply
|
||||
length caps. Default: pass through unchanged."""
|
||||
return markdown
|
||||
|
||||
def conversation_state(self, inbound: InboundMessage) -> dict:
|
||||
"""Channel-specific state to merge into the conversation's extra JSON
|
||||
on each inbound message (e.g. email subject + Message-ID for reply
|
||||
threading). Default: nothing."""
|
||||
return {}
|
||||
|
||||
# --- Optional interactive capabilities (default: unsupported no-ops so
|
||||
# channels without them are simply skipped) ---
|
||||
|
||||
def parse_interaction(self, payload: dict) -> Optional["ChannelInteraction"]:
|
||||
"""Extract a button tap / shared contact from a webhook payload, or
|
||||
None if the payload isn't such an interaction."""
|
||||
return None
|
||||
|
||||
async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None:
|
||||
"""Show a 'typing…' indicator while the agent composes a reply."""
|
||||
return None
|
||||
|
||||
async def mark_read(self, account: ChannelAccount, external_message_id: str) -> None:
|
||||
"""Mark a customer's message read without a typing indicator — for a
|
||||
message something other than the AI answers."""
|
||||
return None
|
||||
|
||||
async def fetch_profile(self, account: ChannelAccount, external_user_id: str) -> dict:
|
||||
"""Look up the customer's real name/email from the platform API when the
|
||||
webhook payload doesn't include it. Returns {} by default."""
|
||||
return {}
|
||||
|
||||
async def request_phone(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
text: str) -> SendResult:
|
||||
"""Ask the customer to share their phone number natively."""
|
||||
return SendResult(ok=False, error=f"{self.channel_type} does not support phone requests")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Channel constants that describe the platforms rather than how we talk to them.
|
||||
|
||||
Separate from meta_base because these are shared by layers that have no
|
||||
business knowing about a transport — request schemas state one as a field
|
||||
default. (It buys no import weight: app/channels/__init__ loads the registry
|
||||
regardless. It is about where the value belongs, not what it drags in.)
|
||||
"""
|
||||
|
||||
import enum
|
||||
from typing import Optional
|
||||
|
||||
# The language assumed when a caller doesn't name one. Meta has no default —
|
||||
# name and language together identify a template, so `order_update` in five
|
||||
# languages is five templates — which makes this our choice, and one worth
|
||||
# keeping in a single place: four copies of the literal would silently disagree
|
||||
# the day it changes.
|
||||
#
|
||||
# The frontend's DEFAULT_LANGUAGE (utils/whatsappLanguages.ts) is the
|
||||
# unavoidable second copy, on the other side of the wire. Keep the two equal.
|
||||
DEFAULT_TEMPLATE_LANGUAGE = "en_US"
|
||||
|
||||
|
||||
class MessageSource(str, enum.Enum):
|
||||
"""Who wrote a message the business sent (chat_history.attributes.source)."""
|
||||
AI = "ai"
|
||||
HUMAN = "human"
|
||||
SYSTEM = "system" # canned notices: handover, limits, errors, tickets
|
||||
APP = "app" # an external app, through a channel extension
|
||||
|
||||
|
||||
# The most choices one reply can offer the customer: WhatsApp's list-message
|
||||
# row cap, the largest interactive form any channel supports.
|
||||
MAX_CHOICE_OPTIONS = 10
|
||||
# The longest one choice can be: a list row's description, which is where a
|
||||
# choice too long for a button or row title is shown in full.
|
||||
MAX_CHOICE_LENGTH = 72
|
||||
|
||||
|
||||
# Channels served by our own chat widget rather than an external messaging
|
||||
# platform. They render our UI, so widget-only features (the rating prompt) work
|
||||
# there; on Telegram or WhatsApp the same prompt is a dead end.
|
||||
#
|
||||
# 'shopify' is that widget embedded in a Shopify storefront — a distinct origin
|
||||
# worth reporting on, but the same chat surface. Every "is this the widget?"
|
||||
# test has to accept both, or Shopify conversations quietly lose widget
|
||||
# features the moment they stop being labelled 'web'.
|
||||
WIDGET_CHANNELS = frozenset({"web", "shopify"})
|
||||
|
||||
|
||||
def is_widget_channel(channel: Optional[str]) -> bool:
|
||||
"""True for our own widget surfaces. A missing channel means the widget."""
|
||||
return not channel or channel in WIDGET_CHANNELS
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
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 hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import parseaddr, make_msgid
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
from app.channels.base import ChannelAdapter, InboundMessage, SendResult
|
||||
from app.channels.registry import register_adapter
|
||||
from app.core.config import settings
|
||||
from app.core.security import decrypt_api_key
|
||||
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def smtp_config(account: ChannelAccount) -> dict:
|
||||
"""Resolve the outbound SMTP settings for an inbox.
|
||||
|
||||
Uses the account's own encrypted SMTP credentials when present (so replies
|
||||
go out from the inbox's own domain with correct SPF/DKIM), otherwise falls
|
||||
back to the platform SMTP settings.
|
||||
"""
|
||||
creds = {}
|
||||
if account.encrypted_credentials:
|
||||
try:
|
||||
creds = json.loads(decrypt_api_key(account.encrypted_credentials))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read email SMTP credentials for {account.id}: {e}")
|
||||
|
||||
if creds.get("smtp_host"):
|
||||
return inbox_smtp_config(creds, account.external_account_id)
|
||||
return platform_smtp_config(account.external_account_id)
|
||||
|
||||
|
||||
def _use_ssl(port: int, use_ssl: Optional[bool]) -> bool:
|
||||
"""Implicit TLS on 465 unless set explicitly; other ports try STARTTLS."""
|
||||
return port == 465 if use_ssl is None else bool(use_ssl)
|
||||
|
||||
|
||||
def inbox_smtp_config(creds: dict, default_from: str) -> dict:
|
||||
"""Connection settings from an inbox's own SMTP credentials (the stored
|
||||
smtp_* keys). `default_from` is used when no from_email was given."""
|
||||
port = int(creds.get("smtp_port") or 587)
|
||||
return {
|
||||
"host": creds["smtp_host"],
|
||||
"port": port,
|
||||
"username": creds.get("smtp_username"),
|
||||
"password": creds.get("smtp_password"),
|
||||
"from_email": creds.get("from_email") or default_from,
|
||||
"use_ssl": _use_ssl(port, creds.get("smtp_use_ssl")),
|
||||
}
|
||||
|
||||
|
||||
def platform_smtp_config(from_email: str) -> dict:
|
||||
"""Connection settings for the platform's own account (SMTP_* settings).
|
||||
With no SMTP_USERNAME there is no login, which is how local Mailpit runs."""
|
||||
port = int(settings.SMTP_PORT)
|
||||
return {
|
||||
"host": settings.SMTP_SERVER,
|
||||
"port": port,
|
||||
"username": settings.SMTP_USERNAME,
|
||||
"password": settings.SMTP_PASSWORD,
|
||||
"from_email": from_email,
|
||||
"use_ssl": _use_ssl(port, None),
|
||||
}
|
||||
|
||||
|
||||
def open_smtp(cfg: dict) -> smtplib.SMTP:
|
||||
"""A connected, logged-in SMTP session for a config built above."""
|
||||
if cfg["use_ssl"]:
|
||||
smtp = smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=SMTP_TIMEOUT_SECONDS)
|
||||
else:
|
||||
smtp = smtplib.SMTP(cfg["host"], cfg["port"], timeout=SMTP_TIMEOUT_SECONDS)
|
||||
try:
|
||||
smtp.starttls()
|
||||
except smtplib.SMTPNotSupportedError:
|
||||
logger.warning("SMTP server does not support STARTTLS; sending unencrypted")
|
||||
if cfg.get("username"):
|
||||
smtp.login(cfg["username"], cfg["password"])
|
||||
return smtp
|
||||
|
||||
|
||||
def validate_smtp(cfg: dict) -> None:
|
||||
"""Open + authenticate an SMTP connection to verify per-inbox credentials.
|
||||
Raises on failure. Runs in a worker thread (smtplib is blocking)."""
|
||||
with open_smtp(cfg) as smtp:
|
||||
smtp.noop()
|
||||
|
||||
SMTP_TIMEOUT_SECONDS = 20.0
|
||||
# Strip quoted history so the agent sees only the new content
|
||||
_REPLY_MARKERS = (
|
||||
re.compile(r"^On .{5,80} wrote:\s*$", re.M),
|
||||
re.compile(r"^-{2,}\s*Original Message\s*-{2,}", re.M | re.I),
|
||||
re.compile(r"^_{10,}\s*$", re.M),
|
||||
)
|
||||
|
||||
|
||||
def strip_quoted_reply(text: str) -> str:
|
||||
"""Cut the email body at the first quoted-history marker."""
|
||||
cut = len(text)
|
||||
for marker in _REPLY_MARKERS:
|
||||
match = marker.search(text)
|
||||
if match:
|
||||
cut = min(cut, match.start())
|
||||
lines = [line for line in text[:cut].splitlines() if not line.startswith(">")]
|
||||
stripped = "\n".join(lines).strip()
|
||||
# Never lose a message to over-eager stripping — fall back to the raw body
|
||||
return stripped or text.strip()
|
||||
|
||||
|
||||
class EmailAdapter(ChannelAdapter):
|
||||
"""Email as a messaging channel.
|
||||
|
||||
Inbound: an inbound-parse webhook (SendGrid/Brevo-style JSON or form post
|
||||
forwarded to /webhooks/email/{account_id}?token=...). Conversations are
|
||||
keyed by the customer's address — one continuous conversation per sender
|
||||
per inbox. Outbound: platform SMTP, with In-Reply-To/References threading
|
||||
from the last inbound Message-ID kept on the conversation."""
|
||||
|
||||
channel_type: ClassVar[str] = ChannelType.EMAIL.value
|
||||
|
||||
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
|
||||
# The per-account secret rides on the URL (?token=), checked in the
|
||||
# route because generic parse providers can't sign requests.
|
||||
return account is not None
|
||||
|
||||
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||||
"""Normalize a parsed-email payload. Accepts the common field names of
|
||||
SendGrid Inbound Parse (from/to/subject/text/headers) and Brevo
|
||||
(From/To/Subject/RawTextBody/MessageId). Machine-generated mail
|
||||
(autoresponders, bounces) is dropped to prevent reply loops."""
|
||||
if self._is_auto_generated(payload):
|
||||
return []
|
||||
sender = payload.get("from") or payload.get("From") or ""
|
||||
if isinstance(sender, dict): # Brevo: {"Address": ..., "Name": ...}
|
||||
sender_name, sender_email = sender.get("Name"), sender.get("Address", "")
|
||||
else:
|
||||
sender_name, sender_email = parseaddr(str(sender))
|
||||
sender_email = (sender_email or "").lower()
|
||||
if not sender_email:
|
||||
return []
|
||||
|
||||
text = payload.get("text") or payload.get("RawTextBody") or payload.get("body") or ""
|
||||
text = strip_quoted_reply(str(text))
|
||||
if not text:
|
||||
return []
|
||||
|
||||
message_id = str(payload.get("MessageId") or payload.get("message_id")
|
||||
or self._header(payload, "Message-ID") or "")
|
||||
subject = str(payload.get("subject") or payload.get("Subject") or "")
|
||||
|
||||
return [InboundMessage(
|
||||
external_account_id="", # resolved from the webhook path
|
||||
external_conversation_id=sender_email,
|
||||
external_user_id=sender_email,
|
||||
external_message_id=message_id or
|
||||
f"{sender_email}:{hashlib.sha256(text.encode()).hexdigest()[:32]}",
|
||||
text=text,
|
||||
profile={"name": sender_name or None, "email": sender_email,
|
||||
"subject": subject, "inbound_message_id": message_id,
|
||||
# Threading headers, kept raw — ticket reply matching
|
||||
# reads the Message-IDs we minted back out of them.
|
||||
"in_reply_to": self._threading_header(payload, "In-Reply-To"),
|
||||
"references": self._threading_header(payload, "References")},
|
||||
)]
|
||||
|
||||
def conversation_state(self, inbound: InboundMessage) -> dict:
|
||||
state = {}
|
||||
if inbound.profile.get("inbound_message_id"):
|
||||
state["last_message_id"] = inbound.profile["inbound_message_id"]
|
||||
if inbound.profile.get("subject"):
|
||||
state["subject"] = inbound.profile["subject"]
|
||||
return state
|
||||
|
||||
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||||
extra = conversation.extra or {}
|
||||
subject = extra.get("subject") or "Re: your message"
|
||||
if subject and not subject.lower().startswith("re:"):
|
||||
subject = f"Re: {subject}"
|
||||
|
||||
cfg = smtp_config(account)
|
||||
message = EmailMessage()
|
||||
message["From"] = cfg["from_email"]
|
||||
message["To"] = conversation.external_conversation_id
|
||||
message["Subject"] = subject
|
||||
message["Message-ID"] = make_msgid()
|
||||
last_inbound_id = extra.get("last_message_id")
|
||||
if last_inbound_id:
|
||||
message["In-Reply-To"] = last_inbound_id
|
||||
message["References"] = last_inbound_id
|
||||
message.set_content(text)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(self._smtp_send, cfg, message)
|
||||
return SendResult(ok=True, external_message_id=message["Message-ID"])
|
||||
except Exception as e:
|
||||
logger.error(f"Email send failed: {e}")
|
||||
return SendResult(ok=False, error=str(e))
|
||||
|
||||
@staticmethod
|
||||
def _smtp_send(cfg: dict, message: EmailMessage) -> None:
|
||||
with open_smtp(cfg) as smtp:
|
||||
smtp.send_message(message)
|
||||
|
||||
@staticmethod
|
||||
def _is_auto_generated(payload: dict) -> bool:
|
||||
"""True for autoresponders/bounces (Auto-Submitted, Precedence bulk,
|
||||
X-Auto-Response-Suppress) — answering those creates mail loops."""
|
||||
headers = payload.get("headers")
|
||||
text = headers if isinstance(headers, str) else ""
|
||||
if isinstance(headers, dict):
|
||||
text = "\n".join(f"{k}: {v}" for k, v in headers.items())
|
||||
lowered = text.lower()
|
||||
if re.search(r"^auto-submitted:\s*(?!no)", lowered, re.M):
|
||||
return True
|
||||
if re.search(r"^precedence:\s*(bulk|junk|auto_reply|list)", lowered, re.M):
|
||||
return True
|
||||
if "x-auto-response-suppress:" in lowered:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _threading_header(cls, payload: dict, name: str) -> str:
|
||||
"""In-Reply-To/References, read from the raw headers the provider
|
||||
forwards (dict or string, both handled by _header). Also accepts a
|
||||
top-level lowercased field for providers that flatten headers onto the
|
||||
payload."""
|
||||
return payload.get(name.lower()) or cls._header(payload, name) or ""
|
||||
|
||||
@staticmethod
|
||||
def _header(payload: dict, name: str) -> Optional[str]:
|
||||
headers = payload.get("headers")
|
||||
if isinstance(headers, dict):
|
||||
return headers.get(name) or headers.get(name.lower())
|
||||
if isinstance(headers, str):
|
||||
match = re.search(rf"^{name}:\s*(.+)$", headers, re.M | re.I)
|
||||
return match.group(1).strip() if match else None
|
||||
return None
|
||||
|
||||
|
||||
def verify_webhook_token(account: ChannelAccount, token: str) -> bool:
|
||||
return bool(token) and hmac.compare_digest(token, account.webhook_secret or "")
|
||||
|
||||
|
||||
register_adapter(EmailAdapter())
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
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.interactive_limits import INSTAGRAM_LIMITS, InteractiveLimits
|
||||
from app.channels.messenger import MessengerAdapter
|
||||
from app.channels.meta_base import GRAPH_INSTAGRAM_BASE
|
||||
from app.channels.registry import register_adapter
|
||||
from app.models.channels import ChannelType
|
||||
|
||||
# Instagram DM uses the same Messenger Platform envelope (entry[].messaging[],
|
||||
# sender.id = IGSID) and the same `me/messages` send shape, so it subclasses
|
||||
# Messenger. What differs is the host and the credential: accounts connect
|
||||
# through Instagram Login, which yields an Instagram user token rather than a
|
||||
# Facebook Page token.
|
||||
|
||||
|
||||
class InstagramAdapter(MessengerAdapter):
|
||||
channel_type: ClassVar[str] = ChannelType.INSTAGRAM.value
|
||||
# An Instagram user token is only accepted by graph.instagram.com; the
|
||||
# Facebook graph rejects it, so sends and profile lookups go here instead.
|
||||
graph_base: ClassVar[str] = GRAPH_INSTAGRAM_BASE
|
||||
# An IG user node exposes name/username, not the Messenger first/last name.
|
||||
profile_fields: ClassVar[str] = "name,username"
|
||||
# Instagram's send body is just {recipient, message} — messaging_type is a
|
||||
# Messenger-only parameter and has no meaning here.
|
||||
send_extras: ClassVar[dict] = {}
|
||||
# Quick replies, but Instagram doesn't show them on desktop or the web,
|
||||
# so the choices are written into the text too
|
||||
interactive_limits: ClassVar[Optional[InteractiveLimits]] = INSTAGRAM_LIMITS
|
||||
# Instagram's own cap on a message's text
|
||||
max_text_length: ClassVar[Optional[int]] = INSTAGRAM_LIMITS.body_max
|
||||
|
||||
def _events(self, entry: dict) -> List[dict]:
|
||||
"""Instagram delivers a DM either as messaging[] or, depending on the
|
||||
API version, as changes[] with field=messages and the same event under
|
||||
`value`. Reading only one shape drops every message on the other, and
|
||||
silently — the payload parses to nothing and the webhook still acks.
|
||||
"""
|
||||
events = super()._events(entry)
|
||||
if events:
|
||||
return events
|
||||
return [change.get("value") or {}
|
||||
for change in entry.get("changes") or []
|
||||
if change.get("field") == "messages"]
|
||||
|
||||
@staticmethod
|
||||
def _display_name(data: dict) -> str:
|
||||
return (data.get("name") or data.get("username") or "").strip()
|
||||
|
||||
|
||||
register_adapter(InstagramAdapter())
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Interactive (choice) messages: validation against a channel's limits, and
|
||||
building one from the agent's plain list of options.
|
||||
|
||||
Checked before sending, so a bad message fails here with a readable reason
|
||||
instead of as an opaque platform 400. Each channel's limits are in
|
||||
channels/interactive_limits.py; the header, footer, section, list-button and
|
||||
link-button limits below are WhatsApp's, the only channel with those parts.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from app.channels.base import (
|
||||
InteractiveButton, InteractiveKind, InteractiveMessage, InteractiveRow,
|
||||
InteractiveSection,
|
||||
)
|
||||
from app.channels.constants import MAX_CHOICE_OPTIONS
|
||||
from app.channels.interactive_limits import WHATSAPP_LIMITS, InteractiveLimits
|
||||
|
||||
SECTION_TITLE_MAX = 24
|
||||
LIST_BUTTON_LABEL_MAX = 20
|
||||
CTA_DISPLAY_TEXT_MAX = 20
|
||||
HEADER_MAX = 60
|
||||
FOOTER_MAX = 60
|
||||
|
||||
DEFAULT_LIST_BUTTON_LABEL = "Choose"
|
||||
# The body of the choices when the reply itself was too long to carry them
|
||||
DEFAULT_CHOICES_BODY = "Choose an option:"
|
||||
ELLIPSIS = "…"
|
||||
|
||||
# Ids we give the agent's options
|
||||
_QUICK_REPLY_ID_PREFIX = "qr_"
|
||||
|
||||
|
||||
def _quick_reply_id(index: int) -> str:
|
||||
return f"{_QUICK_REPLY_ID_PREFIX}{index}"
|
||||
|
||||
|
||||
def truncate(text: str, limit: int) -> str:
|
||||
"""Cut to `limit` characters, marking the cut with an ellipsis."""
|
||||
text = text.strip()
|
||||
return text if len(text) <= limit else text[:limit - len(ELLIPSIS)].rstrip() + ELLIPSIS
|
||||
|
||||
|
||||
def from_options(body: str, options: List[str],
|
||||
limits: InteractiveLimits = WHATSAPP_LIMITS) -> InteractiveMessage:
|
||||
"""Build the message for the agent's options on a channel.
|
||||
|
||||
Buttons when there are few enough options and every one fits a button
|
||||
title, or when the channel has no lists (titles are cut to fit; the full
|
||||
text is kept with each); otherwise a list, whose rows allow longer titles
|
||||
and carry the full text in the description when the title had to be cut.
|
||||
"""
|
||||
options = options[:MAX_CHOICE_OPTIONS]
|
||||
fits = len(options) <= limits.max_buttons and all(len(o) <= limits.button_title_max for o in options)
|
||||
if fits or not limits.supports_list:
|
||||
return InteractiveMessage(
|
||||
kind=InteractiveKind.BUTTONS,
|
||||
body=body,
|
||||
buttons=[InteractiveButton(id=_quick_reply_id(i), title=truncate(o, limits.button_title_max),
|
||||
description=o if len(o) > limits.button_title_max else None)
|
||||
for i, o in enumerate(options[:limits.max_buttons])],
|
||||
)
|
||||
rows = [
|
||||
InteractiveRow(
|
||||
id=_quick_reply_id(i),
|
||||
title=truncate(o, limits.row_title_max),
|
||||
description=truncate(o, limits.row_description_max) if len(o) > limits.row_title_max else None,
|
||||
)
|
||||
for i, o in enumerate(options[:limits.max_list_rows])
|
||||
]
|
||||
return InteractiveMessage(
|
||||
kind=InteractiveKind.LIST,
|
||||
body=body,
|
||||
list_button_label=DEFAULT_LIST_BUTTON_LABEL,
|
||||
sections=[InteractiveSection(rows=rows)],
|
||||
)
|
||||
|
||||
|
||||
def validate_interactive(message: InteractiveMessage,
|
||||
limits: InteractiveLimits = WHATSAPP_LIMITS) -> List[str]:
|
||||
"""Every reason the channel would reject this message; empty when valid."""
|
||||
errors: List[str] = []
|
||||
_check_text(errors, "body", message.body, limits.body_max, required=True)
|
||||
_check_text(errors, "header", message.header, HEADER_MAX)
|
||||
_check_text(errors, "footer", message.footer, FOOTER_MAX)
|
||||
|
||||
if message.kind == InteractiveKind.BUTTONS:
|
||||
_validate_buttons(errors, message.buttons, limits)
|
||||
elif message.kind == InteractiveKind.LIST:
|
||||
if limits.supports_list:
|
||||
_validate_list(errors, message, limits)
|
||||
else:
|
||||
errors.append("this channel can't show a list")
|
||||
elif message.kind == InteractiveKind.CTA_URL:
|
||||
if limits.supports_cta:
|
||||
_validate_cta(errors, message)
|
||||
else:
|
||||
errors.append("this channel can't show a link button")
|
||||
return errors
|
||||
|
||||
|
||||
def to_text_fallback(message: InteractiveMessage, max_length: Optional[int] = None) -> str:
|
||||
"""The same choices as plain text, for when the interactive form can't be
|
||||
sent: the customer can still answer by typing the option. When the
|
||||
channel caps message length, the body is shortened — never the choices."""
|
||||
if message.kind == InteractiveKind.CTA_URL:
|
||||
tail = f"{message.display_text}: {message.url}" if message.display_text else message.url
|
||||
suffix = f"\n{tail}"
|
||||
else:
|
||||
lines = [f"{i}. {_fallback_line(c)}" for i, c in enumerate(all_choices(message), start=1)]
|
||||
suffix = "\n\n" + "\n".join(lines) if lines else ""
|
||||
body = message.body
|
||||
if max_length is not None and len(body) + len(suffix) > max_length:
|
||||
body = truncate(body, max(max_length - len(suffix), len(ELLIPSIS)))
|
||||
return body + suffix
|
||||
|
||||
|
||||
def choice_text(choice) -> str:
|
||||
"""The full text of one choice: its title, or for a button or row whose
|
||||
title had to be cut, its description (see from_options)."""
|
||||
description = getattr(choice, "description", None)
|
||||
if description and choice.title.endswith(ELLIPSIS):
|
||||
return description
|
||||
return choice.title
|
||||
|
||||
|
||||
def all_choices(message: InteractiveMessage) -> list:
|
||||
"""Every button and list row, in order."""
|
||||
return list(message.buttons) + [r for s in message.sections for r in s.rows]
|
||||
|
||||
|
||||
def _fallback_line(choice) -> str:
|
||||
text = choice_text(choice)
|
||||
description = getattr(choice, "description", None)
|
||||
# A real description (not the cut title's full text) is worth showing too
|
||||
return f"{text} — {description}" if description and description != text else text
|
||||
|
||||
|
||||
def _check_text(errors: List[str], name: str, value: Optional[str], limit: int,
|
||||
required: bool = False) -> None:
|
||||
if not value or not value.strip():
|
||||
if required:
|
||||
errors.append(f"{name} is required")
|
||||
return
|
||||
if len(value) > limit:
|
||||
errors.append(f"{name} must be at most {limit} characters")
|
||||
|
||||
|
||||
def _check_ids(errors: List[str], name: str, ids: List[str], limits: InteractiveLimits) -> None:
|
||||
if any(not i or len(i.encode()) > limits.id_max_bytes for i in ids):
|
||||
errors.append(f"every {name} id must be 1-{limits.id_max_bytes} bytes")
|
||||
if len(set(ids)) != len(ids):
|
||||
errors.append(f"{name} ids must be unique")
|
||||
|
||||
|
||||
def _validate_buttons(errors: List[str], buttons: List[InteractiveButton], limits: InteractiveLimits) -> None:
|
||||
if not 1 <= len(buttons) <= limits.max_buttons:
|
||||
errors.append(f"buttons needs 1-{limits.max_buttons} buttons")
|
||||
for button in buttons:
|
||||
_check_text(errors, "button title", button.title, limits.button_title_max, required=True)
|
||||
_check_ids(errors, "button", [b.id for b in buttons], limits)
|
||||
|
||||
|
||||
def _validate_list(errors: List[str], message: InteractiveMessage, limits: InteractiveLimits) -> None:
|
||||
_check_text(errors, "list button label", message.list_button_label,
|
||||
LIST_BUTTON_LABEL_MAX, required=True)
|
||||
rows = [r for s in message.sections for r in s.rows]
|
||||
if not message.sections or any(not s.rows for s in message.sections):
|
||||
errors.append("every list section needs at least one row")
|
||||
if not 1 <= len(rows) <= limits.max_list_rows:
|
||||
errors.append(f"a list needs 1-{limits.max_list_rows} rows in total")
|
||||
if len(message.sections) > 1 and any(not s.title for s in message.sections):
|
||||
errors.append("section titles are required when there is more than one section")
|
||||
for section in message.sections:
|
||||
_check_text(errors, "section title", section.title, SECTION_TITLE_MAX)
|
||||
for row in rows:
|
||||
_check_text(errors, "row title", row.title, limits.row_title_max, required=True)
|
||||
_check_text(errors, "row description", row.description, limits.row_description_max)
|
||||
_check_ids(errors, "row", [r.id for r in rows], limits)
|
||||
|
||||
|
||||
def _validate_cta(errors: List[str], message: InteractiveMessage) -> None:
|
||||
_check_text(errors, "display text", message.display_text, CTA_DISPLAY_TEXT_MAX, required=True)
|
||||
if not (message.url or "").lower().startswith("https://"):
|
||||
errors.append("url must be an https:// link")
|
||||
@@ -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.
|
||||
|
||||
What each channel's tappable choices can hold. Dependency-free so adapters
|
||||
(channels/base.py) and the builders (channels/interactive.py) can share it.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.channels.constants import MAX_CHOICE_LENGTH, MAX_CHOICE_OPTIONS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InteractiveLimits:
|
||||
max_buttons: int
|
||||
button_title_max: int
|
||||
body_max: int
|
||||
# Choice ids are checked in bytes: Telegram counts bytes, and so does
|
||||
# every other platform's limit once an id isn't ASCII
|
||||
id_max_bytes: int
|
||||
supports_list: bool = False
|
||||
max_list_rows: int = 0
|
||||
row_title_max: int = 0
|
||||
row_description_max: int = 0
|
||||
supports_cta: bool = False
|
||||
# Also write the choices into the text: where some of the platform's
|
||||
# apps don't show the buttons (Instagram on desktop and the web)
|
||||
options_in_text: bool = False
|
||||
|
||||
|
||||
# https://developers.facebook.com/docs/whatsapp/cloud-api/messages/interactive-reply-buttons-messages
|
||||
WHATSAPP_LIMITS = InteractiveLimits(
|
||||
max_buttons=3, button_title_max=20, body_max=1024, id_max_bytes=256,
|
||||
supports_list=True, max_list_rows=MAX_CHOICE_OPTIONS, row_title_max=24,
|
||||
row_description_max=MAX_CHOICE_LENGTH, supports_cta=True,
|
||||
)
|
||||
|
||||
# Quick replies: https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies
|
||||
# body_max is each channel's cap on a message's text, which the adapters use too
|
||||
MESSENGER_LIMITS = InteractiveLimits(max_buttons=13, button_title_max=20, body_max=2000, id_max_bytes=1000)
|
||||
|
||||
# Same quick replies, but Instagram doesn't show them on desktop or the web
|
||||
INSTAGRAM_LIMITS = InteractiveLimits(max_buttons=13, button_title_max=20, body_max=1000, id_max_bytes=1000,
|
||||
options_in_text=True)
|
||||
|
||||
# Inline keyboard: https://core.telegram.org/bots/api#inlinekeyboardbutton
|
||||
TELEGRAM_LIMITS = InteractiveLimits(max_buttons=MAX_CHOICE_OPTIONS, button_title_max=64, body_max=4096,
|
||||
id_max_bytes=64)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
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
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.channels.base import ChannelAdapter, InboundMessage, SendResult
|
||||
from app.channels.registry import register_adapter
|
||||
from app.core.security import decrypt_api_key
|
||||
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
|
||||
from app.core.logger import get_logger
|
||||
from app.core.external_urls import external_base
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
LINE_API_BASE = external_base("LINE_API_BASE", "https://api.line.me/v2/bot")
|
||||
REQUEST_TIMEOUT_SECONDS = 15.0
|
||||
# https://developers.line.biz/en/reference/messaging-api/#text-message
|
||||
MAX_MESSAGE_LENGTH = 5000
|
||||
# Loading-animation duration: 5–60, in multiples of 5 (auto-dismissed on reply)
|
||||
LOADING_ANIMATION_SECONDS = 20
|
||||
|
||||
_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
|
||||
|
||||
|
||||
def credentials(account: ChannelAccount) -> dict:
|
||||
return json.loads(decrypt_api_key(account.encrypted_credentials))
|
||||
|
||||
|
||||
async def get_bot_info(channel_access_token: str) -> Optional[dict]:
|
||||
"""Validate a channel access token; returns the bot info or None."""
|
||||
try:
|
||||
response = await _get_http_client().get(
|
||||
f"{LINE_API_BASE}/info",
|
||||
headers={"Authorization": f"Bearer {channel_access_token}"},
|
||||
)
|
||||
return response.json() if response.status_code < 300 else None
|
||||
except Exception as e:
|
||||
logger.error(f"LINE bot info failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def set_webhook_endpoint(channel_access_token: str, url: str) -> bool:
|
||||
"""Point the LINE channel's webhook at us (best-effort; can also be set in
|
||||
the LINE Developers console)."""
|
||||
try:
|
||||
response = await _get_http_client().put(
|
||||
f"{LINE_API_BASE}/channel/webhook/endpoint",
|
||||
headers={"Authorization": f"Bearer {channel_access_token}"},
|
||||
json={"endpoint": url},
|
||||
)
|
||||
return response.status_code < 300
|
||||
except Exception as e:
|
||||
logger.error(f"LINE set webhook failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class LineAdapter(ChannelAdapter):
|
||||
"""LINE Messaging API. Conversations are keyed by the LINE userId; replies
|
||||
go out as push messages (reply tokens expire too quickly for AI latency)."""
|
||||
|
||||
channel_type: ClassVar[str] = ChannelType.LINE.value
|
||||
|
||||
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
|
||||
"""X-Line-Signature: base64(HMAC-SHA256(channel_secret, body))."""
|
||||
if account is None:
|
||||
return False
|
||||
signature = headers.get("x-line-signature", "")
|
||||
try:
|
||||
secret = credentials(account).get("channel_secret", "")
|
||||
except Exception as e:
|
||||
logger.error(f"LINE credential decrypt failed for {account.id}: {e}")
|
||||
return False
|
||||
if not signature or not secret:
|
||||
return False
|
||||
expected = base64.b64encode(
|
||||
hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
|
||||
).decode()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||||
"""Normalize webhook events; only one-on-one text messages become
|
||||
InboundMessages."""
|
||||
messages: List[InboundMessage] = []
|
||||
for event in payload.get("events", []):
|
||||
message = event.get("message") or {}
|
||||
source = event.get("source") or {}
|
||||
if event.get("type") != "message" or message.get("type") != "text":
|
||||
continue
|
||||
user_id = source.get("userId")
|
||||
if not user_id:
|
||||
continue
|
||||
timestamp = None
|
||||
if event.get("timestamp"):
|
||||
timestamp = datetime.fromtimestamp(event["timestamp"] / 1000, tz=timezone.utc)
|
||||
messages.append(InboundMessage(
|
||||
external_account_id=str(payload.get("destination", "")),
|
||||
external_conversation_id=str(user_id),
|
||||
external_user_id=str(user_id),
|
||||
external_message_id=str(message.get("id", "")),
|
||||
text=message.get("text", ""),
|
||||
timestamp=timestamp,
|
||||
))
|
||||
return messages
|
||||
|
||||
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||||
try:
|
||||
response = await _get_http_client().post(
|
||||
f"{LINE_API_BASE}/message/push",
|
||||
headers={"Authorization": f"Bearer {credentials(account)['channel_access_token']}"},
|
||||
json={
|
||||
"to": conversation.external_conversation_id,
|
||||
"messages": [{"type": "text", "text": text}],
|
||||
},
|
||||
)
|
||||
if response.status_code >= 300:
|
||||
try:
|
||||
error = response.json().get("message", f"HTTP {response.status_code}")
|
||||
except Exception:
|
||||
error = f"HTTP {response.status_code}"
|
||||
return SendResult(ok=False, error=str(error))
|
||||
return SendResult(ok=True)
|
||||
except Exception as e:
|
||||
logger.error(f"LINE send failed: {e}")
|
||||
return SendResult(ok=False, error=str(e))
|
||||
|
||||
async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None:
|
||||
# LINE's loading animation (1:1 chats only); auto-dismisses when the
|
||||
# reply is sent or after loadingSeconds.
|
||||
try:
|
||||
await _get_http_client().post(
|
||||
f"{LINE_API_BASE}/chat/loading/start",
|
||||
headers={"Authorization": f"Bearer {credentials(account)['channel_access_token']}"},
|
||||
json={
|
||||
"chatId": conversation.external_conversation_id,
|
||||
"loadingSeconds": LOADING_ANIMATION_SECONDS,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"LINE loading animation failed (non-critical): {e}")
|
||||
|
||||
def format_outbound(self, markdown: str) -> str:
|
||||
return markdown[:MAX_MESSAGE_LENGTH]
|
||||
|
||||
|
||||
register_adapter(LineAdapter())
|
||||
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
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 (
|
||||
InboundMessage, InteractiveKind, InteractiveMessage, InteractiveReply, InteractiveReplyType, SendResult,
|
||||
)
|
||||
from app.channels.interactive import to_text_fallback
|
||||
from app.channels.interactive_limits import MESSENGER_LIMITS, InteractiveLimits
|
||||
from app.channels.meta_base import GRAPH_BASE, MetaBaseAdapter, graph_get, graph_post
|
||||
from app.channels.registry import register_adapter
|
||||
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Messenger send API limit
|
||||
MAX_MESSAGE_LENGTH = MESSENGER_LIMITS.body_max
|
||||
|
||||
|
||||
class MessengerAdapter(MetaBaseAdapter):
|
||||
"""Facebook Messenger. Instagram DM shares the same entry[].messaging[]
|
||||
envelope and send shape, so InstagramAdapter subclasses this — overriding
|
||||
only the host it talks to and the fields that name a sender."""
|
||||
|
||||
channel_type: ClassVar[str] = ChannelType.MESSENGER.value
|
||||
# Instagram Login accounts hold an Instagram user token, which only
|
||||
# graph.instagram.com accepts; Page tokens only work on the Facebook graph.
|
||||
graph_base: ClassVar[str] = GRAPH_BASE
|
||||
# Graph fields on the sender node that carry a display name. Instagram user
|
||||
# nodes expose different ones, so it overrides this and _display_name.
|
||||
profile_fields: ClassVar[str] = "first_name,last_name"
|
||||
# Extra keys on a send. messaging_type is a Messenger Platform parameter and
|
||||
# is not part of Instagram's documented send body, so Instagram sends none.
|
||||
send_extras: ClassVar[dict] = {"messaging_type": "RESPONSE"}
|
||||
# Choices go as quick replies (buttons only: no lists or link buttons)
|
||||
interactive_limits: ClassVar[Optional[InteractiveLimits]] = MESSENGER_LIMITS
|
||||
max_text_length: ClassVar[Optional[int]] = MAX_MESSAGE_LENGTH
|
||||
|
||||
@staticmethod
|
||||
def _event_seconds(raw) -> Optional[int]:
|
||||
"""Event time in seconds, or None if absent/unparseable.
|
||||
|
||||
Messenger sends milliseconds as a number; Instagram's changes payload
|
||||
sends seconds as a string, which the old `// 1000` raised a TypeError on
|
||||
— crashing the whole webhook rather than dropping one timestamp.
|
||||
"""
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
# 13-digit values are milliseconds; 10-digit ones are already seconds.
|
||||
return value // 1000 if value > 10_000_000_000 else value
|
||||
|
||||
def _events(self, entry: dict) -> List[dict]:
|
||||
"""The messaging events on one entry. Messenger always uses messaging[];
|
||||
Instagram overrides this because it can deliver the same event under
|
||||
changes[] instead."""
|
||||
return entry.get("messaging") or []
|
||||
|
||||
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||||
"""Normalize per-entry messaging events. Echoes, delivery/read receipts
|
||||
and attachment-only messages yield nothing."""
|
||||
messages: List[InboundMessage] = []
|
||||
for entry in payload.get("entry", []):
|
||||
account_id = str(entry.get("id", ""))
|
||||
for event in self._events(entry):
|
||||
message = event.get("message")
|
||||
if not message or message.get("is_echo"):
|
||||
continue
|
||||
text = message.get("text")
|
||||
if not text:
|
||||
continue
|
||||
sender_id = str((event.get("sender") or {}).get("id", ""))
|
||||
if not sender_id:
|
||||
continue
|
||||
messages.append(InboundMessage(
|
||||
external_account_id=account_id,
|
||||
external_conversation_id=sender_id,
|
||||
external_user_id=sender_id,
|
||||
external_message_id=str(message.get("mid", "")),
|
||||
text=text,
|
||||
profile={},
|
||||
timestamp=self._timestamp(self._event_seconds(event.get("timestamp"))),
|
||||
interactive=_quick_reply(message),
|
||||
# Only a swipe-reply carries this; a quick-reply tap doesn't
|
||||
reply_to_message_id=(message.get("reply_to") or {}).get("mid"),
|
||||
))
|
||||
return messages
|
||||
|
||||
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||||
return await self._send(account, conversation, {"text": text})
|
||||
|
||||
async def send_interactive(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
message: InteractiveMessage) -> SendResult:
|
||||
"""The choices as quick replies under the text — and, where some of the
|
||||
platform's apps don't show quick replies, written into it as well."""
|
||||
if message.kind != InteractiveKind.BUTTONS:
|
||||
return SendResult(ok=False, error=f"{self.channel_type} only sends choices as quick replies")
|
||||
limits = self.interactive_limits
|
||||
text = to_text_fallback(message, limits.body_max) if limits.options_in_text else message.body
|
||||
return await self._send(account, conversation, {
|
||||
"text": text,
|
||||
"quick_replies": [{"content_type": "text", "title": b.title, "payload": b.id}
|
||||
for b in message.buttons],
|
||||
})
|
||||
|
||||
async def _send(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
message: dict) -> SendResult:
|
||||
# `me/messages` resolves to the page/account owning the access token
|
||||
return await graph_post(
|
||||
"me/messages",
|
||||
self.access_token(account),
|
||||
{
|
||||
"recipient": {"id": conversation.external_conversation_id},
|
||||
**self.send_extras,
|
||||
"message": message,
|
||||
},
|
||||
base=self.graph_base,
|
||||
)
|
||||
|
||||
async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None:
|
||||
"""Dismissed automatically when the reply is delivered, or after ~20s."""
|
||||
try:
|
||||
result = await graph_post(
|
||||
"me/messages",
|
||||
self.access_token(account),
|
||||
{
|
||||
"recipient": {"id": conversation.external_conversation_id},
|
||||
"sender_action": "typing_on",
|
||||
},
|
||||
base=self.graph_base,
|
||||
)
|
||||
if not result.ok:
|
||||
logger.debug(f"{self.channel_type} typing_on failed (non-critical): {result.error}")
|
||||
except Exception as e:
|
||||
logger.debug(f"{self.channel_type} typing_on failed (non-critical): {e}")
|
||||
|
||||
def format_outbound(self, markdown: str) -> str:
|
||||
return markdown[:self.max_text_length]
|
||||
|
||||
async def fetch_profile(self, account: ChannelAccount, external_user_id: str) -> dict:
|
||||
"""The customer's name, which the webhook never carries — Messenger
|
||||
sends only a PSID, so without this every customer shows as
|
||||
"Messenger user 2742…" in People.
|
||||
|
||||
Best-effort: a lookup failure must not cost us the message, so it
|
||||
degrades to {} (the placeholder stays) rather than raising. Reading a
|
||||
sender's name needs no permission beyond messaging them.
|
||||
"""
|
||||
ok, data = await graph_get(external_user_id, self.access_token(account),
|
||||
params={"fields": self.profile_fields},
|
||||
base=self.graph_base)
|
||||
if not ok:
|
||||
return {}
|
||||
name = self._display_name(data)
|
||||
return {"name": name} if name else {}
|
||||
|
||||
@staticmethod
|
||||
def _display_name(data: dict) -> str:
|
||||
return " ".join(p for p in (data.get("first_name"), data.get("last_name")) if p).strip()
|
||||
|
||||
|
||||
def _quick_reply(message: dict) -> Optional[InteractiveReply]:
|
||||
"""A tapped quick reply: its payload is the choice's id, the text its title."""
|
||||
payload = (message.get("quick_reply") or {}).get("payload")
|
||||
if not payload:
|
||||
return None
|
||||
return InteractiveReply(type=InteractiveReplyType.BUTTON_REPLY, id=str(payload), title=message.get("text") or "")
|
||||
|
||||
|
||||
register_adapter(MessengerAdapter())
|
||||
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
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 hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timezone
|
||||
from typing import ClassVar, Iterable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.channels.base import ChannelAdapter, SendResult, WindowStatus
|
||||
from app.core.config import settings
|
||||
from app.core.security import decrypt_credentials
|
||||
from app.models.channels import ChannelAccount, ChannelConversation
|
||||
from app.core.logger import get_logger
|
||||
from app.core.external_urls import external_base
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
GRAPH_BASE = external_base("META_GRAPH_BASE", "https://graph.facebook.com")
|
||||
# Instagram API with Instagram Login speaks to its own host with an Instagram
|
||||
# user token; the Facebook graph rejects those. Page-based channels keep using
|
||||
# GRAPH_BASE, so every helper below takes the base rather than assuming one.
|
||||
GRAPH_INSTAGRAM_BASE = external_base("META_GRAPH_INSTAGRAM_BASE", "https://graph.instagram.com")
|
||||
INSTAGRAM_OAUTH_BASE = "https://api.instagram.com"
|
||||
REQUEST_TIMEOUT_SECONDS = 15.0
|
||||
SEND_RETRIES = 2
|
||||
# Graph error codes meaning the access token is unusable: 190 expired or
|
||||
# invalidated token, 102 expired session, 0 WhatsApp's "Authentication Error"
|
||||
GRAPH_AUTH_ERROR_CODES = frozenset({0, 102, 190})
|
||||
# Meta customer-service window: free-form replies allowed for 24h after the
|
||||
# customer's last inbound message (applies to WhatsApp, Messenger and Instagram).
|
||||
WINDOW_HOURS = 24
|
||||
|
||||
# Shared keep-alive client to graph.facebook.com (process-lifetime, like the
|
||||
# Telegram adapter's) — avoids a TCP+TLS handshake per outbound message.
|
||||
_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
|
||||
|
||||
|
||||
def graph_url(path: str, base: str = GRAPH_BASE) -> str:
|
||||
return f"{base}/{settings.META_GRAPH_VERSION}/{path}"
|
||||
|
||||
|
||||
def verify_meta_signature(raw_body: bytes, signature_header: str) -> bool:
|
||||
"""Validate the X-Hub-Signature-256 header against our app secrets.
|
||||
|
||||
The signature covers the raw request body; it must be checked before the
|
||||
body is parsed. Uses a constant-time comparison.
|
||||
|
||||
Instagram Login webhooks are signed with the Instagram app secret rather
|
||||
than the Meta one, and the only thing that would say which product sent this
|
||||
is inside the body we have not authenticated yet. So this tries each secret
|
||||
we own instead of trusting unverified content to choose one; an attacker
|
||||
still has to forge one of them.
|
||||
"""
|
||||
return signed_by_any(raw_body, signature_header,
|
||||
(settings.META_APP_SECRET, settings.INSTAGRAM_APP_SECRET))
|
||||
|
||||
|
||||
def signed_by_any(raw_body: bytes, signature_header: str, app_secrets: Iterable[Optional[str]]) -> bool:
|
||||
"""Whether X-Hub-Signature-256 was made with one of these app secrets
|
||||
(constant-time; empty secrets are skipped)."""
|
||||
if not signature_header or not signature_header.startswith("sha256="):
|
||||
return False
|
||||
provided = signature_header[len("sha256="):]
|
||||
for app_secret in app_secrets:
|
||||
if not app_secret:
|
||||
continue
|
||||
expected = hmac.new(app_secret.encode(), raw_body, hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(expected, provided):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def verify_challenge(mode: Optional[str], token: Optional[str], challenge: Optional[str]) -> Optional[str]:
|
||||
"""Answer a webhook GET verification handshake.
|
||||
|
||||
Returns the challenge string to echo when the subscribe token matches, else
|
||||
None (caller should 403).
|
||||
"""
|
||||
if mode == "subscribe" and token and token == settings.META_WEBHOOK_VERIFY_TOKEN:
|
||||
return challenge
|
||||
return None
|
||||
|
||||
|
||||
async def graph_post(path: str, access_token: str, payload: dict,
|
||||
base: str = GRAPH_BASE) -> SendResult:
|
||||
"""POST to the Graph API with small exponential backoff on transient errors.
|
||||
|
||||
Returns a SendResult; 4xx errors are terminal (no retry), network/5xx are
|
||||
retried up to SEND_RETRIES times. `base` selects the host — Instagram Login
|
||||
accounts send via graph.instagram.com, everything else via graph.facebook.com.
|
||||
"""
|
||||
url = graph_url(path, base)
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
last_error = "unknown error"
|
||||
for attempt in range(SEND_RETRIES + 1):
|
||||
try:
|
||||
response = await _get_http_client().post(url, json=payload, headers=headers)
|
||||
if response.status_code < 300:
|
||||
data = response.json()
|
||||
message_id = _extract_message_id(data)
|
||||
return SendResult(ok=True, external_message_id=message_id)
|
||||
# 4xx are our fault (bad token, closed window) — don't retry
|
||||
if response.status_code < 500:
|
||||
return SendResult(ok=False, error=_graph_error(response),
|
||||
auth_failed=_is_auth_error(response))
|
||||
last_error = _graph_error(response)
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
if attempt < SEND_RETRIES:
|
||||
await asyncio.sleep(0.5 * (2 ** attempt))
|
||||
return SendResult(ok=False, error=last_error)
|
||||
|
||||
|
||||
async def _graph_request(method: str, path: str, access_token: Optional[str],
|
||||
params: Optional[dict] = None,
|
||||
json_body: Optional[dict] = None,
|
||||
base: str = GRAPH_BASE) -> tuple[bool, dict]:
|
||||
"""Issue a Graph call and return (ok, decoded-json).
|
||||
|
||||
Unlike graph_post this keeps the whole response body, for calls where the
|
||||
body is the result rather than just a message id. The token goes in the
|
||||
Authorization header so it never appears in URLs or proxy logs; pass None
|
||||
for the one endpoint that authenticates with app credentials instead. These
|
||||
are management calls, not sends, so a failure surfaces to the caller
|
||||
instead of being retried.
|
||||
"""
|
||||
try:
|
||||
response = await _get_http_client().request(
|
||||
method,
|
||||
graph_url(path, base),
|
||||
params=params or None,
|
||||
json=json_body,
|
||||
headers={"Authorization": f"Bearer {access_token}"} if access_token else {},
|
||||
)
|
||||
return (response.status_code < 300, response.json())
|
||||
except Exception as e:
|
||||
logger.error(f"Graph {method} {path} failed: {e}")
|
||||
return (False, {"error": {"message": str(e)}})
|
||||
|
||||
|
||||
async def graph_get(path: str, access_token: str, params: Optional[dict] = None,
|
||||
base: str = GRAPH_BASE) -> tuple[bool, dict]:
|
||||
"""GET a Graph node (validating tokens during onboarding, listing templates)."""
|
||||
return await _graph_request("GET", path, access_token, params=params, base=base)
|
||||
|
||||
|
||||
def graph_detail(data: dict, fallback: str) -> str:
|
||||
"""Meta's own error text where it has one, so the UI can show the real
|
||||
reason (e.g. a template that cannot be deleted) rather than a generic
|
||||
failure.
|
||||
|
||||
error_user_msg first: `message` is often a generic OAuth string that says
|
||||
nothing actionable — Graph will pair a `message` of "Application does not
|
||||
have permission for this action" with an error_user_msg that names the
|
||||
actual asset and rule. Only one of those is a clue.
|
||||
"""
|
||||
error = data.get("error", {})
|
||||
if not isinstance(error, dict):
|
||||
return fallback
|
||||
return error.get("error_user_msg") or error.get("message") or fallback
|
||||
|
||||
|
||||
async def graph_post_json(path: str, access_token: str, payload: dict) -> tuple[bool, dict]:
|
||||
"""POST to a Graph node, keeping the response body (e.g. creating a message
|
||||
template returns its id, status and category)."""
|
||||
return await _graph_request("POST", path, access_token, json_body=payload)
|
||||
|
||||
|
||||
async def debug_token(input_token: str) -> tuple[bool, dict]:
|
||||
"""Inspect a token: what it is, who it belongs to, and whether it is ours.
|
||||
|
||||
Returns Meta's `data` object, which for a Page token carries
|
||||
`type: "PAGE"`, `profile_id` (the page id), `app_id`, `is_valid` and the
|
||||
granted `scopes`.
|
||||
|
||||
This authenticates with app credentials rather than the token being
|
||||
inspected, so it needs no permission on the asset. Reading the Page node
|
||||
directly (`me?fields=id,name`) would instead demand `pages_read_engagement`
|
||||
— unrelated to messaging, and absent from a token that can nonetheless send
|
||||
perfectly well.
|
||||
"""
|
||||
app_token = f"{settings.META_APP_ID}|{settings.META_APP_SECRET}"
|
||||
ok, data = await _graph_request("GET", "debug_token", app_token,
|
||||
params={"input_token": input_token})
|
||||
if not ok:
|
||||
return False, data
|
||||
return True, data.get("data", {})
|
||||
|
||||
|
||||
async def exchange_signup_code(code: str, redirect_uri: Optional[str] = None) -> tuple[bool, dict]:
|
||||
"""Trade a login code for the customer's access token.
|
||||
|
||||
The app credentials authenticate this call, so it is the one Graph request
|
||||
that carries no bearer token.
|
||||
|
||||
redirect_uri must match whatever the login dialog used, or Graph rejects the
|
||||
code as a mismatch:
|
||||
- WhatsApp Embedded Signup issues its code through the JS SDK with no
|
||||
redirect_uri, so it passes None and we omit the param.
|
||||
- Facebook Login for Business runs as a first-party OAuth popup bound to
|
||||
our own callback URL, so that flow passes that exact callback URL.
|
||||
"""
|
||||
params = {
|
||||
"client_id": settings.META_APP_ID,
|
||||
"client_secret": settings.META_APP_SECRET,
|
||||
"code": code,
|
||||
}
|
||||
if redirect_uri is not None:
|
||||
params["redirect_uri"] = redirect_uri
|
||||
return await _graph_request("GET", "oauth/access_token", None, params=params)
|
||||
|
||||
|
||||
async def exchange_for_long_lived_token(short_lived_token: str) -> tuple[bool, dict]:
|
||||
"""Trade a user token for a 60-day one.
|
||||
|
||||
Page tokens inherit their lifetime from the user token they were read with:
|
||||
from a short-lived one they die in about an hour, from a long-lived one they
|
||||
never expire. We store the Page token and send with it for months, so this
|
||||
runs before /me/accounts. An already-long-lived token comes back unchanged,
|
||||
so it is safe to call unconditionally. App credentials authenticate it, so
|
||||
like exchange_signup_code it carries no bearer token.
|
||||
"""
|
||||
return await _graph_request("GET", "oauth/access_token", None, params={
|
||||
"grant_type": "fb_exchange_token",
|
||||
"client_id": settings.META_APP_ID,
|
||||
"client_secret": settings.META_APP_SECRET,
|
||||
"fb_exchange_token": short_lived_token,
|
||||
})
|
||||
|
||||
|
||||
def instagram_token_payload(data: dict) -> dict:
|
||||
"""The token object out of an Instagram Login exchange response.
|
||||
|
||||
Instagram has returned this both flat and wrapped in a single-element `data`
|
||||
list depending on the API version, and the difference is invisible until it
|
||||
silently yields no token — so accept either shape.
|
||||
"""
|
||||
entries = data.get("data")
|
||||
if isinstance(entries, list) and entries and isinstance(entries[0], dict):
|
||||
return entries[0]
|
||||
return data
|
||||
|
||||
|
||||
async def exchange_instagram_code(code: str, redirect_uri: str) -> tuple[bool, dict]:
|
||||
"""Trade an Instagram Business Login code for a short-lived user token.
|
||||
|
||||
Instagram Login is a separate OAuth from the Facebook graph: its own host,
|
||||
its own app credentials, form-encoded rather than query params, and it
|
||||
returns an Instagram user token plus the account's user_id — no Page and no
|
||||
Page token anywhere in the flow.
|
||||
"""
|
||||
try:
|
||||
response = await _get_http_client().request(
|
||||
"POST",
|
||||
f"{INSTAGRAM_OAUTH_BASE}/oauth/access_token",
|
||||
data={
|
||||
"client_id": settings.INSTAGRAM_APP_ID,
|
||||
"client_secret": settings.INSTAGRAM_APP_SECRET,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri,
|
||||
"code": code,
|
||||
},
|
||||
)
|
||||
return (response.status_code < 300, response.json())
|
||||
except Exception as e:
|
||||
logger.error(f"Instagram code exchange failed: {e}")
|
||||
return (False, {"error": {"message": str(e)}})
|
||||
|
||||
|
||||
async def exchange_instagram_long_lived(short_lived_token: str) -> tuple[bool, dict]:
|
||||
"""Trade a short-lived Instagram token (~1 hour) for a 60-day one.
|
||||
|
||||
Without this the connection dies about an hour after onboarding, with no
|
||||
signal beyond messages quietly failing.
|
||||
"""
|
||||
try:
|
||||
response = await _get_http_client().request(
|
||||
"GET",
|
||||
f"{GRAPH_INSTAGRAM_BASE}/access_token",
|
||||
params={
|
||||
"grant_type": "ig_exchange_token",
|
||||
"client_secret": settings.INSTAGRAM_APP_SECRET,
|
||||
"access_token": short_lived_token,
|
||||
},
|
||||
)
|
||||
return (response.status_code < 300, response.json())
|
||||
except Exception as e:
|
||||
logger.error(f"Instagram long-lived exchange failed: {e}")
|
||||
return (False, {"error": {"message": str(e)}})
|
||||
|
||||
|
||||
async def subscribe_instagram_app(access_token: str) -> bool:
|
||||
"""Subscribe our app to this Instagram account's message webhooks.
|
||||
|
||||
Best-effort, like subscribe_app: onboarding still succeeds if it fails, and
|
||||
it can be retried by reconnecting.
|
||||
"""
|
||||
ok, _ = await _graph_request("POST", "me/subscribed_apps", access_token,
|
||||
params={"subscribed_fields": "messages"},
|
||||
base=GRAPH_INSTAGRAM_BASE)
|
||||
return ok
|
||||
|
||||
|
||||
async def register_phone_number(phone_number_id: str, access_token: str, pin: str) -> tuple[bool, dict]:
|
||||
"""Enable a number for Cloud API sending.
|
||||
|
||||
A number onboarded through Embedded Signup is attached to the WABA but not
|
||||
yet usable; registering it with a two-step-verification PIN is what makes it
|
||||
able to send.
|
||||
"""
|
||||
return await _graph_request("POST", f"{phone_number_id}/register", access_token,
|
||||
json_body={"messaging_product": "whatsapp", "pin": pin})
|
||||
|
||||
|
||||
# Template fields worth reading back from Graph; components carries the body
|
||||
# text and any {{n}} variables the UI has to prompt for.
|
||||
TEMPLATE_FIELDS = "name,status,category,language,components"
|
||||
TEMPLATE_PAGE_LIMIT = 100
|
||||
# Backstop against paging forever on a malformed cursor. Well above Meta's own
|
||||
# per-WABA template ceiling, so a real account never reaches it.
|
||||
TEMPLATE_MAX_PAGES = 10
|
||||
|
||||
|
||||
async def graph_list_all(path: str, access_token: str, params: dict,
|
||||
max_pages: int = TEMPLATE_MAX_PAGES) -> tuple[bool, list | dict]:
|
||||
"""Every node on a Graph edge, following its cursor.
|
||||
|
||||
A node that exists but isn't listed is an invisible failure — a Page the
|
||||
customer administers but never sees offered, a template an agent cannot pick
|
||||
— so this pages rather than stopping at the first page. Returns
|
||||
(True, [node dicts]) or (False, graph error body), matching the other
|
||||
helpers here. A `limit` in params lets a short final page end the walk one
|
||||
round-trip early; without one it stops when Graph offers no `after` cursor.
|
||||
"""
|
||||
items: list = []
|
||||
limit = params.get("limit")
|
||||
for _ in range(max_pages):
|
||||
ok, data = await graph_get(path, access_token, params=params)
|
||||
if not ok:
|
||||
return False, data
|
||||
page = data.get("data")
|
||||
if not isinstance(page, list):
|
||||
return False, data
|
||||
items.extend(page)
|
||||
|
||||
after = (data.get("paging") or {}).get("cursors", {}).get("after")
|
||||
if not after or (limit is not None and len(page) < limit):
|
||||
return True, items
|
||||
params = {**params, "after": after}
|
||||
|
||||
logger.warning(f"Stopped paging {path} at {max_pages} pages")
|
||||
return True, items
|
||||
|
||||
|
||||
async def fetch_message_templates(waba_id: str, access_token: str) -> tuple[bool, list | dict]:
|
||||
"""Every template on the WABA (see graph_list_all for the paging rationale)."""
|
||||
return await graph_list_all(
|
||||
f"{waba_id}/message_templates", access_token,
|
||||
{"fields": TEMPLATE_FIELDS, "limit": TEMPLATE_PAGE_LIMIT})
|
||||
|
||||
|
||||
async def subscribe_app(node_id: str, access_token: str, subscribed_fields: Optional[str] = None) -> bool:
|
||||
"""Subscribe our app to a Page/WABA so its messages reach our webhook.
|
||||
Best-effort: onboarding still succeeds if this fails (can be retried)."""
|
||||
payload = {}
|
||||
if subscribed_fields:
|
||||
payload["subscribed_fields"] = subscribed_fields
|
||||
result = await graph_post(f"{node_id}/subscribed_apps", access_token, payload)
|
||||
return result.ok
|
||||
|
||||
|
||||
def _extract_message_id(data: dict) -> Optional[str]:
|
||||
# WhatsApp: {"messages":[{"id":...}]}; Messenger/IG: {"message_id":...}
|
||||
if isinstance(data.get("messages"), list) and data["messages"]:
|
||||
return str(data["messages"][0].get("id", "")) or None
|
||||
return data.get("message_id")
|
||||
|
||||
|
||||
def graph_auth_refused(body) -> bool:
|
||||
"""Whether a Graph error body says the access token itself is unusable
|
||||
(expired, revoked, or never valid), as opposed to refusing this one call."""
|
||||
error = body.get("error") if isinstance(body, dict) else None
|
||||
return isinstance(error, dict) and error.get("code") in GRAPH_AUTH_ERROR_CODES
|
||||
|
||||
|
||||
def _is_auth_error(response: httpx.Response) -> bool:
|
||||
if response.status_code == 401:
|
||||
return True
|
||||
try:
|
||||
return graph_auth_refused(response.json())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _graph_error(response: httpx.Response) -> str:
|
||||
try:
|
||||
body = response.json()
|
||||
return str(body.get("error", {}).get("message") or body)
|
||||
except Exception:
|
||||
return f"HTTP {response.status_code}"
|
||||
|
||||
|
||||
class MetaBaseAdapter(ChannelAdapter):
|
||||
"""Shared behavior for the Meta messaging channels (WhatsApp, Messenger,
|
||||
Instagram): one webhook signature scheme, one Graph client, one 24h
|
||||
customer-service window. Subclasses implement parse_inbound and send_text."""
|
||||
|
||||
# What an expired window means for this channel: WhatsApp can reopen with a
|
||||
# template, Messenger/Instagram simply cannot deliver.
|
||||
expired_status: ClassVar[WindowStatus] = WindowStatus.UNDELIVERABLE
|
||||
|
||||
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
|
||||
return verify_meta_signature(raw_body, headers.get("x-hub-signature-256", ""))
|
||||
|
||||
def check_delivery_window(self, conversation: ChannelConversation) -> WindowStatus:
|
||||
last_inbound = conversation.last_inbound_at
|
||||
if last_inbound is None:
|
||||
return self.expired_status
|
||||
if last_inbound.tzinfo is None:
|
||||
last_inbound = last_inbound.replace(tzinfo=timezone.utc)
|
||||
age_hours = (datetime.now(timezone.utc) - last_inbound).total_seconds() / 3600
|
||||
return WindowStatus.OK if age_hours < WINDOW_HOURS else self.expired_status
|
||||
|
||||
@staticmethod
|
||||
def access_token(account: ChannelAccount) -> str:
|
||||
"""Decrypt the account's Graph access token (page or WABA token)."""
|
||||
return decrypt_credentials(account.encrypted_credentials)["access_token"]
|
||||
|
||||
@staticmethod
|
||||
def _timestamp(value) -> Optional[datetime]:
|
||||
try:
|
||||
return datetime.fromtimestamp(int(value), tz=timezone.utc)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
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 Dict, Optional
|
||||
|
||||
from app.channels.base import ChannelAdapter
|
||||
|
||||
_adapters: Dict[str, ChannelAdapter] = {}
|
||||
|
||||
|
||||
def register_adapter(adapter: ChannelAdapter) -> ChannelAdapter:
|
||||
"""Register a stateless adapter singleton for its channel_type."""
|
||||
_adapters[adapter.channel_type] = adapter
|
||||
return adapter
|
||||
|
||||
|
||||
def get_adapter(channel_type: str) -> Optional[ChannelAdapter]:
|
||||
"""Adapter for a channel, or None for channels without one ('web')."""
|
||||
_ensure_loaded()
|
||||
return _adapters.get(channel_type)
|
||||
|
||||
|
||||
_loaded = False
|
||||
|
||||
|
||||
def _ensure_loaded() -> None:
|
||||
"""Import adapter modules on first use (each registers itself).
|
||||
Lazy so importing app.channels never pulls every adapter's deps."""
|
||||
global _loaded
|
||||
if _loaded:
|
||||
return
|
||||
_loaded = True
|
||||
from app.channels import telegram # noqa: F401
|
||||
from app.channels import whatsapp # noqa: F401
|
||||
from app.channels import messenger # noqa: F401
|
||||
from app.channels import instagram # noqa: F401
|
||||
from app.channels import slack # noqa: F401
|
||||
from app.channels import email # noqa: F401
|
||||
from app.channels import sms # noqa: F401
|
||||
from app.channels import line # noqa: F401
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
The stored record of the choices a message actually went out with — what the
|
||||
inbox shows under it, and what a later tap on one of them is read against.
|
||||
Encrypted: the choices can echo what the customer said.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from app.channels.base import InteractiveMessage
|
||||
from app.channels.interactive import all_choices, choice_text
|
||||
from app.core.encryption import decrypt_json, encrypt_json
|
||||
|
||||
# chat_history.attributes key the record is stored under
|
||||
SENT_CHOICES_ATTRIBUTE = "interactive_sent"
|
||||
|
||||
|
||||
def encode_sent_choices(message: InteractiveMessage, as_text: bool, numbered: bool = False) -> dict:
|
||||
"""The record for one sent message; `as_text` when the choices had to go
|
||||
as a numbered list instead of buttons; `numbered` when the customer saw
|
||||
them numbered either way (so a bare "2" picks the second)."""
|
||||
return encrypt_json({
|
||||
"kind": message.kind.value,
|
||||
"as_text": as_text,
|
||||
"numbered": as_text or numbered,
|
||||
"choices": [{"id": c.id, "text": choice_text(c)} for c in all_choices(message)],
|
||||
})
|
||||
|
||||
|
||||
def decode_sent_choices(value) -> Optional[dict]:
|
||||
record = decrypt_json(value)
|
||||
return record if isinstance(record, dict) else None
|
||||
|
||||
|
||||
def sent_numbered_choice(record: Optional[dict], typed: str) -> Optional[str]:
|
||||
"""The full text of the choice the customer typed the number of (1-based),
|
||||
when the record's choices were shown numbered. A number that is itself
|
||||
one of the choices ("2" guests) is that choice, not a position."""
|
||||
choices = (record or {}).get("choices") or []
|
||||
if not (record or {}).get("numbered") or not typed.isdecimal():
|
||||
return None
|
||||
if any((choice.get("text") or "").strip() == typed for choice in choices):
|
||||
return None
|
||||
number = int(typed)
|
||||
return choices[number - 1].get("text") if 1 <= number <= len(choices) else None
|
||||
|
||||
|
||||
def sent_choice_text(record: Optional[dict], choice_id: str) -> Optional[str]:
|
||||
"""The full text of the choice with this id in a sent-choices record."""
|
||||
for choice in (record or {}).get("choices") or []:
|
||||
if choice.get("id") == choice_id:
|
||||
return choice.get("text")
|
||||
return None
|
||||
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
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
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.channels.base import ChannelAdapter, InboundMessage, SendResult
|
||||
from app.channels.registry import register_adapter
|
||||
from app.core.config import settings
|
||||
from app.core.security import decrypt_api_key
|
||||
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
|
||||
from app.core.logger import get_logger
|
||||
from app.core.external_urls import external_base
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
SLACK_API_BASE = external_base("SLACK_API_BASE", "https://slack.com/api")
|
||||
REQUEST_TIMEOUT_SECONDS = 15.0
|
||||
# Reject webhook timestamps older than this (replay protection, per Slack docs)
|
||||
MAX_SIGNATURE_AGE_SECONDS = 60 * 5
|
||||
# Slack recommends keeping messages well under the 40k hard limit
|
||||
MAX_MESSAGE_LENGTH = 12000
|
||||
|
||||
# App Home / Assistant (Agents & AI Apps) copy — kept in one place so the tone
|
||||
# stays consistent across the Messages tab, the Home tab, and the sidebar.
|
||||
MAX_SUGGESTED_PROMPTS = 4 # Slack caps setSuggestedPrompts at 4
|
||||
ASSISTANT_READY_STATUS = "is getting ready…"
|
||||
WELCOME_WITH_AGENT = (
|
||||
"Hi! I'm {agent}. 👋\n\n"
|
||||
"Ask me anything and I'll help right away — I answer from your team's "
|
||||
"knowledge and can bring in a human when you need one."
|
||||
)
|
||||
WELCOME_NO_AGENT = (
|
||||
"Hi! I'm ChatterMate. 👋\n\n"
|
||||
"No agent is connected to this workspace yet. Set one up in ChatterMate and "
|
||||
"I'll start answering here."
|
||||
)
|
||||
SUGGESTED_PROMPTS_TITLE = "Try asking:"
|
||||
STARTER_PROMPTS = [
|
||||
{"title": "What can you help with?", "message": "What can you help me with?"},
|
||||
{"title": "Talk to a human", "message": "I'd like to talk to a human."},
|
||||
]
|
||||
|
||||
_MENTION_RE = re.compile(r"<@[A-Z0-9]+>")
|
||||
|
||||
_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
|
||||
|
||||
|
||||
async def slack_api(method: str, access_token: str, payload: dict, form: bool = False) -> dict:
|
||||
# Write methods (chat.postMessage/update) accept JSON; read/query methods
|
||||
# like users.info only read args from form-encoded params.
|
||||
kwargs = {"data": payload} if form else {"json": payload}
|
||||
response = await _get_http_client().post(
|
||||
f"{SLACK_API_BASE}/{method}",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
**kwargs,
|
||||
)
|
||||
return response.json()
|
||||
|
||||
|
||||
# --- App Home / Assistant API wrappers (Agents & AI Apps) -------------------
|
||||
# All take JSON, so they go through slack_api directly. Status and prompts are
|
||||
# cosmetic — failures are logged, never raised, so a hiccup can't break the flow.
|
||||
|
||||
async def set_assistant_status(token: str, channel_id: str, thread_ts: str, status: str) -> dict:
|
||||
"""Show a transient status line (e.g. 'is thinking…') in an assistant thread."""
|
||||
data = await slack_api("assistant.threads.setStatus", token,
|
||||
{"channel_id": channel_id, "thread_ts": thread_ts, "status": status})
|
||||
if not data.get("ok"):
|
||||
logger.debug(f"Slack assistant.threads.setStatus: {data.get('error')}")
|
||||
return data
|
||||
|
||||
|
||||
async def set_suggested_prompts(token: str, channel_id: str, thread_ts: str,
|
||||
prompts: List[dict], title: Optional[str] = None) -> dict:
|
||||
"""Offer starter prompts in an assistant thread. `prompts` is capped at 4."""
|
||||
payload = {"channel_id": channel_id, "thread_ts": thread_ts,
|
||||
"prompts": prompts[:MAX_SUGGESTED_PROMPTS]}
|
||||
if title:
|
||||
payload["title"] = title
|
||||
data = await slack_api("assistant.threads.setSuggestedPrompts", token, payload)
|
||||
if not data.get("ok"):
|
||||
logger.debug(f"Slack assistant.threads.setSuggestedPrompts: {data.get('error')}")
|
||||
return data
|
||||
|
||||
|
||||
async def publish_home_view(token: str, user_id: str, view: dict) -> dict:
|
||||
"""Publish the App Home (Home tab) view for one user."""
|
||||
data = await slack_api("views.publish", token, {"user_id": user_id, "view": view})
|
||||
if not data.get("ok"):
|
||||
logger.error(f"Slack views.publish failed: {data.get('error')}")
|
||||
return data
|
||||
|
||||
|
||||
# Home-tab presentation. The card is pre-resolved by the caller (photo URL
|
||||
# signed) so this stays a pure, testable Block Kit builder. Per Slack's App Home
|
||||
# guidance: lead with what the app does, surface the most relevant thing (the
|
||||
# connected agent), keep call-to-actions minimal, link out to settings.
|
||||
HOME_INSTRUCTION_MAX = 250
|
||||
_STATUS_ONLINE = ":large_green_circle: Online"
|
||||
_STATUS_OFFLINE = ":white_circle: Offline"
|
||||
HOME_INTRO = (
|
||||
"AI customer support, right inside Slack. @mention me in a channel and I'll "
|
||||
"answer in the thread, or send me a direct message. My answers come from your "
|
||||
"team's own knowledge, and I hand off to a human when a conversation needs one."
|
||||
)
|
||||
HOME_HOW_TO = (
|
||||
"*How to use me*\n"
|
||||
"• @mention *ChatterMate* in any channel — I reply in the thread\n"
|
||||
"• Send me a *direct message* for a private conversation\n"
|
||||
"• Open me from the *assistant* in the top bar for a side-by-side chat"
|
||||
)
|
||||
HOME_NO_AGENT = (
|
||||
"*No agent connected yet*\nConnect an agent to this workspace from the "
|
||||
"ChatterMate dashboard and I'll start answering here."
|
||||
)
|
||||
DASHBOARD_LINK_LABEL = "Open the ChatterMate dashboard →"
|
||||
|
||||
|
||||
def build_home_view(agent_card: Optional[dict], dashboard_url: str) -> dict:
|
||||
"""Assemble the App Home view: what the app does, the connected agent, how to
|
||||
use it, and a link to the dashboard.
|
||||
|
||||
`agent_card` (or None if no agent is assigned): {"name", "is_active",
|
||||
"instruction", "photo_url" | None}.
|
||||
"""
|
||||
blocks: List[dict] = [
|
||||
{"type": "header", "text": {"type": "plain_text", "text": "ChatterMate", "emoji": True}},
|
||||
{"type": "section", "text": {"type": "mrkdwn", "text": HOME_INTRO}},
|
||||
{"type": "divider"},
|
||||
]
|
||||
|
||||
if agent_card:
|
||||
status = _STATUS_ONLINE if agent_card.get("is_active") else _STATUS_OFFLINE
|
||||
header: List[dict] = []
|
||||
if agent_card.get("photo_url"):
|
||||
header.append({"type": "image", "image_url": agent_card["photo_url"],
|
||||
"alt_text": agent_card["name"]})
|
||||
header.append({"type": "mrkdwn", "text": f"*{agent_card['name']}* · {status}"})
|
||||
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": "*Connected agent*"}})
|
||||
blocks.append({"type": "context", "elements": header})
|
||||
|
||||
instruction = (agent_card.get("instruction") or "").strip()
|
||||
if instruction:
|
||||
if len(instruction) > HOME_INSTRUCTION_MAX:
|
||||
instruction = instruction[:HOME_INSTRUCTION_MAX - 1].rstrip() + "…"
|
||||
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": instruction}})
|
||||
else:
|
||||
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": HOME_NO_AGENT}})
|
||||
|
||||
blocks.append({"type": "divider"})
|
||||
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": HOME_HOW_TO}})
|
||||
# A markdown link, not a Block Kit button — link buttons still require
|
||||
# Interactivity to be enabled, which this app intentionally leaves off.
|
||||
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": f"<{dashboard_url}|{DASHBOARD_LINK_LABEL}>"}})
|
||||
|
||||
return {"type": "home", "blocks": blocks}
|
||||
|
||||
|
||||
# Placeholder "typing…" message ts per (account, conversation) — set by
|
||||
# send_typing, consumed by send_text which edits it into the real reply.
|
||||
# In-memory is fine: a message's send_typing + send_text run in the same
|
||||
# background task / worker. Each entry is (ts, created_at); stale entries are
|
||||
# pruned so a bypassed send_text (e.g. AI error, empty reply) can't leak them.
|
||||
_typing_placeholders: dict = {}
|
||||
# A placeholder is only useful for the seconds between typing and the reply;
|
||||
# anything older is a leak from a path that never called send_text.
|
||||
_PLACEHOLDER_TTL_SECONDS = 300
|
||||
|
||||
|
||||
def _prune_placeholders(now: float) -> None:
|
||||
stale = [key for key, (_, created) in _typing_placeholders.items()
|
||||
if now - created > _PLACEHOLDER_TTL_SECONDS]
|
||||
for key in stale:
|
||||
_typing_placeholders.pop(key, None)
|
||||
|
||||
|
||||
def verify_slack_signature(headers: dict, raw_body: bytes) -> bool:
|
||||
"""Validate X-Slack-Signature (v0 HMAC-SHA256 over 'v0:{ts}:{body}') with
|
||||
replay protection. Fails closed when SLACK_SIGNING_SECRET is unset."""
|
||||
signing_secret = settings.SLACK_SIGNING_SECRET
|
||||
timestamp = headers.get("x-slack-request-timestamp", "")
|
||||
signature = headers.get("x-slack-signature", "")
|
||||
if not signing_secret or not timestamp or not signature:
|
||||
return False
|
||||
try:
|
||||
if abs(time.time() - int(timestamp)) > MAX_SIGNATURE_AGE_SECONDS:
|
||||
return False
|
||||
except ValueError:
|
||||
return False
|
||||
base = f"v0:{timestamp}:".encode() + raw_body
|
||||
expected = "v0=" + hmac.new(signing_secret.encode(), base, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
class SlackAdapter(ChannelAdapter):
|
||||
"""Slack as a customer messaging channel on the unified stack.
|
||||
|
||||
Conversation keying: DMs to the bot use the IM channel id (one continuous
|
||||
conversation, like Telegram); @mentions in channels use `{channel}:{thread_ts}`
|
||||
so each thread is its own conversation and replies stay in-thread.
|
||||
"""
|
||||
|
||||
channel_type: ClassVar[str] = ChannelType.SLACK.value
|
||||
|
||||
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
|
||||
return verify_slack_signature(headers, raw_body)
|
||||
|
||||
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||||
"""Normalize an Events API callback. Handles app_mention and direct
|
||||
messages; bot echoes, edits, and other subtypes yield nothing."""
|
||||
event = payload.get("event") or {}
|
||||
event_type = event.get("type")
|
||||
team_id = payload.get("team_id", "")
|
||||
|
||||
is_mention = event_type == "app_mention"
|
||||
is_dm = event_type == "message" and event.get("channel_type") == "im"
|
||||
if not (is_mention or is_dm):
|
||||
return []
|
||||
# Ignore bot messages and subtypes (edits, joins, message_changed...)
|
||||
if event.get("bot_id") or event.get("subtype"):
|
||||
return []
|
||||
|
||||
text = _MENTION_RE.sub("", event.get("text") or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
channel = event.get("channel", "")
|
||||
ts = event.get("ts", "")
|
||||
if is_mention:
|
||||
thread_ts = event.get("thread_ts") or ts
|
||||
conversation_id = f"{channel}:{thread_ts}"
|
||||
elif event.get("thread_ts"):
|
||||
# Assistant-sidebar messages are DMs that carry the assistant thread's
|
||||
# ts; key by thread so the reply posts back into that thread. Plain
|
||||
# DMs have no thread_ts and stay one continuous conversation.
|
||||
conversation_id = f"{channel}:{event['thread_ts']}"
|
||||
else:
|
||||
conversation_id = channel
|
||||
|
||||
timestamp = None
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(float(ts), tz=timezone.utc)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return [InboundMessage(
|
||||
external_account_id=team_id,
|
||||
external_conversation_id=conversation_id,
|
||||
external_user_id=str(event.get("user", "")),
|
||||
external_message_id=payload.get("event_id") or ts,
|
||||
text=text,
|
||||
timestamp=timestamp,
|
||||
)]
|
||||
|
||||
async def fetch_profile(self, account: ChannelAccount, external_user_id: str) -> dict:
|
||||
"""Resolve the Slack user's real name (and email, if the scope allows)
|
||||
via users.info so customers show as a name, not a raw U0… id."""
|
||||
try:
|
||||
data = await slack_api("users.info", self._access_token(account),
|
||||
{"user": external_user_id}, form=True)
|
||||
if not data.get("ok"):
|
||||
return {}
|
||||
user = data.get("user") or {}
|
||||
profile = user.get("profile") or {}
|
||||
name = (user.get("real_name") or profile.get("display_name")
|
||||
or profile.get("real_name"))
|
||||
return {"name": name or None, "email": profile.get("email")}
|
||||
except Exception as e:
|
||||
logger.error(f"Slack users.info failed: {e}")
|
||||
return {}
|
||||
|
||||
async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None:
|
||||
# Slack has no bot typing indicator; post a placeholder we later edit
|
||||
# into the real reply so the customer sees an immediate "typing…".
|
||||
channel, thread_ts = self._split_conversation(conversation.external_conversation_id)
|
||||
payload = {"channel": channel, "text": "_typing…_", "mrkdwn": True}
|
||||
if thread_ts:
|
||||
payload["thread_ts"] = thread_ts
|
||||
try:
|
||||
_prune_placeholders(time.time())
|
||||
data = await slack_api("chat.postMessage", self._access_token(account), payload)
|
||||
if data.get("ok"):
|
||||
_typing_placeholders[self._placeholder_key(account, conversation)] = (data["ts"], time.time())
|
||||
except Exception as e:
|
||||
logger.debug(f"Slack typing placeholder failed (non-critical): {e}")
|
||||
|
||||
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||||
channel, thread_ts = self._split_conversation(conversation.external_conversation_id)
|
||||
token = self._access_token(account)
|
||||
|
||||
# If we posted a "typing…" placeholder, edit it into the reply
|
||||
entry = _typing_placeholders.pop(self._placeholder_key(account, conversation), None)
|
||||
placeholder_ts = entry[0] if entry else None
|
||||
if placeholder_ts:
|
||||
try:
|
||||
data = await slack_api("chat.update", token,
|
||||
{"channel": channel, "ts": placeholder_ts, "text": text})
|
||||
if data.get("ok"):
|
||||
return SendResult(ok=True, external_message_id=str(data.get("ts", "")))
|
||||
# Update failed (e.g. placeholder deleted) — remove the stale
|
||||
# "typing…" so we don't leave it beside the fresh reply.
|
||||
await slack_api("chat.delete", token, {"channel": channel, "ts": placeholder_ts})
|
||||
except Exception as e:
|
||||
logger.debug(f"Slack chat.update failed, posting new message: {e}")
|
||||
|
||||
payload = {"channel": channel, "text": text}
|
||||
if thread_ts:
|
||||
payload["thread_ts"] = thread_ts
|
||||
try:
|
||||
data = await slack_api("chat.postMessage", token, payload)
|
||||
if not data.get("ok"):
|
||||
return SendResult(ok=False, error=str(data.get("error") or "chat.postMessage failed"))
|
||||
return SendResult(ok=True, external_message_id=str(data.get("ts", "")))
|
||||
except Exception as e:
|
||||
logger.error(f"Slack send failed: {e}")
|
||||
return SendResult(ok=False, error=str(e))
|
||||
|
||||
def format_outbound(self, markdown: str) -> str:
|
||||
# Slack mrkdwn uses single markers: **bold** -> *bold*
|
||||
return markdown.replace("**", "*")[:MAX_MESSAGE_LENGTH]
|
||||
|
||||
@staticmethod
|
||||
def _placeholder_key(account: ChannelAccount, conversation: ChannelConversation) -> tuple:
|
||||
return (str(account.id), conversation.external_conversation_id)
|
||||
|
||||
@staticmethod
|
||||
def _split_conversation(conversation_id: str) -> tuple[str, Optional[str]]:
|
||||
if ":" in conversation_id:
|
||||
channel, thread_ts = conversation_id.split(":", 1)
|
||||
return channel, thread_ts
|
||||
return conversation_id, None
|
||||
|
||||
@staticmethod
|
||||
def _access_token(account: ChannelAccount) -> str:
|
||||
return json.loads(decrypt_api_key(account.encrypted_credentials))["access_token"]
|
||||
|
||||
|
||||
register_adapter(SlackAdapter())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
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 hmac
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.channels.base import (
|
||||
ChannelAdapter, ChannelInteraction, InboundMessage, InteractiveKind, InteractiveMessage,
|
||||
InteractiveReply, InteractiveReplyType, SendResult,
|
||||
)
|
||||
from app.channels.interactive_limits import TELEGRAM_LIMITS, InteractiveLimits
|
||||
from app.channels.registry import register_adapter
|
||||
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
|
||||
from app.core.security import decrypt_api_key
|
||||
from app.core.logger import get_logger
|
||||
from app.core.external_urls import external_base
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
TELEGRAM_API_BASE = external_base("TELEGRAM_API_BASE", "https://api.telegram.org")
|
||||
# https://core.telegram.org/bots/api#sendmessage
|
||||
MAX_MESSAGE_LENGTH = TELEGRAM_LIMITS.body_max
|
||||
REQUEST_TIMEOUT_SECONDS = 15.0
|
||||
|
||||
# Shared client: keep-alive connection pooling to api.telegram.org instead of
|
||||
# a TCP+TLS handshake per call (process-lifetime, like the DB engine).
|
||||
_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
|
||||
|
||||
|
||||
async def _call_api(bot_token: str, method: str, payload: Optional[dict] = None) -> dict:
|
||||
"""Call a Telegram Bot API method; returns the decoded JSON envelope."""
|
||||
url = f"{TELEGRAM_API_BASE}/bot{bot_token}/{method}"
|
||||
response = await _get_http_client().post(url, json=payload or {})
|
||||
return response.json()
|
||||
|
||||
|
||||
async def get_me(bot_token: str) -> Optional[dict]:
|
||||
"""Validate a bot token; returns the bot's user object or None."""
|
||||
try:
|
||||
data = await _call_api(bot_token, "getMe")
|
||||
return data.get("result") if data.get("ok") else None
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram getMe failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def set_webhook(bot_token: str, url: str, secret_token: str) -> tuple[bool, str]:
|
||||
"""Register our webhook with Telegram. Returns (ok, error_description) —
|
||||
the description surfaces Telegram's reason (e.g. 'an HTTPS URL must be
|
||||
provided for webhook' when BACKEND_URL points at localhost)."""
|
||||
try:
|
||||
data = await _call_api(bot_token, "setWebhook", {
|
||||
"url": url,
|
||||
"secret_token": secret_token,
|
||||
# message covers customer texts and shared contacts;
|
||||
# callback_query, taps on the choices we send
|
||||
"allowed_updates": ["message", "callback_query"],
|
||||
})
|
||||
if data.get("ok"):
|
||||
return True, ""
|
||||
description = str(data.get("description") or "setWebhook failed")
|
||||
logger.error(f"Telegram setWebhook rejected for {url}: {description}")
|
||||
return False, description
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram setWebhook failed: {e}")
|
||||
return False, str(e)
|
||||
|
||||
|
||||
async def delete_webhook(bot_token: str) -> bool:
|
||||
try:
|
||||
data = await _call_api(bot_token, "deleteWebhook")
|
||||
return bool(data.get("ok"))
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram deleteWebhook failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# A tap's message id, kept apart from message ids (which only count up per chat)
|
||||
CALLBACK_ID_PREFIX = "cbq:"
|
||||
PRIVATE_CHAT = "private"
|
||||
TAP_NOT_TAKEN = "This choice is no longer available"
|
||||
|
||||
|
||||
def callback_answer(payload: dict, taken: bool) -> Optional[dict]:
|
||||
"""The webhook response that answers a tap — Telegram keeps the button
|
||||
spinning until a tap is answered, and accepts the answer as the reply to
|
||||
its webhook request. A tap we didn't act on (a second tap, a group chat,
|
||||
an old message) says so. None for any other update."""
|
||||
callback = payload.get("callback_query") or {}
|
||||
if not callback.get("id"):
|
||||
return None
|
||||
answer = {"method": "answerCallbackQuery", "callback_query_id": callback["id"]}
|
||||
if not taken:
|
||||
answer["text"] = TAP_NOT_TAKEN
|
||||
return answer
|
||||
|
||||
|
||||
def dedupe_id(inbound: InboundMessage) -> str:
|
||||
"""What makes an update a repeat: a tap's keyboard (one answer per
|
||||
keyboard), otherwise the message itself."""
|
||||
if inbound.interactive is not None and inbound.reply_to_message_id:
|
||||
return f"keyboard:{inbound.reply_to_message_id}"
|
||||
return inbound.external_message_id
|
||||
|
||||
|
||||
class TelegramAdapter(ChannelAdapter):
|
||||
channel_type: ClassVar[str] = ChannelType.TELEGRAM.value
|
||||
# Choices go as an inline keyboard (buttons only)
|
||||
interactive_limits: ClassVar[Optional[InteractiveLimits]] = TELEGRAM_LIMITS
|
||||
max_text_length: ClassVar[Optional[int]] = MAX_MESSAGE_LENGTH
|
||||
|
||||
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
|
||||
"""Telegram echoes the secret_token given to setWebhook on every request."""
|
||||
if account is None:
|
||||
return False
|
||||
provided = headers.get("x-telegram-bot-api-secret-token", "")
|
||||
return hmac.compare_digest(provided, account.webhook_secret or "")
|
||||
|
||||
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||||
"""Normalize a Telegram Update. Plain user text messages and taps on
|
||||
our buttons become InboundMessages; bot echoes, shared contacts
|
||||
(handled as interactions) and service updates are ignored."""
|
||||
if payload.get("callback_query"):
|
||||
tap = _tap(payload["callback_query"])
|
||||
return [tap] if tap is not None else []
|
||||
message = payload.get("message") or {}
|
||||
sender = message.get("from") or {}
|
||||
chat = message.get("chat") or {}
|
||||
if not message or sender.get("is_bot") or "id" not in chat:
|
||||
return []
|
||||
|
||||
text = message.get("text") or message.get("caption")
|
||||
if not text:
|
||||
return [] # media / contact-only messages aren't customer text turns
|
||||
|
||||
name = " ".join(filter(None, [sender.get("first_name"), sender.get("last_name")])) or sender.get("username")
|
||||
timestamp = None
|
||||
if message.get("date"):
|
||||
timestamp = datetime.fromtimestamp(message["date"], tz=timezone.utc)
|
||||
|
||||
return [InboundMessage(
|
||||
external_account_id="", # resolved from the webhook path, not the payload
|
||||
external_conversation_id=str(chat["id"]),
|
||||
external_user_id=str(sender.get("id", chat["id"])),
|
||||
external_message_id=str(message.get("message_id", "")),
|
||||
text=text,
|
||||
profile={"name": name, "username": sender.get("username")},
|
||||
timestamp=timestamp,
|
||||
)]
|
||||
|
||||
def parse_interaction(self, payload: dict):
|
||||
"""Detect a shared contact (message.contact) — the share-phone flow.
|
||||
Bypasses the AI pipeline."""
|
||||
message = payload.get("message") or {}
|
||||
contact = message.get("contact")
|
||||
chat = message.get("chat") or {}
|
||||
if contact and "id" in chat:
|
||||
sender = message.get("from") or {}
|
||||
# Only accept the sender's OWN contact (the share-phone button flow);
|
||||
# a forwarded third-party contact card has a different/absent user_id.
|
||||
if contact.get("user_id") != sender.get("id"):
|
||||
return None
|
||||
return ChannelInteraction(
|
||||
type="contact",
|
||||
external_account_id="",
|
||||
external_conversation_id=str(chat["id"]),
|
||||
external_user_id=str(sender.get("id", chat["id"])),
|
||||
phone=contact.get("phone_number"),
|
||||
profile={"name": " ".join(filter(None, [contact.get("first_name"), contact.get("last_name")]))},
|
||||
)
|
||||
return None
|
||||
|
||||
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||||
return await self._send_message(account, conversation.external_conversation_id, text)
|
||||
|
||||
def choice_limits_for(self, conversation: ChannelConversation) -> Optional[InteractiveLimits]:
|
||||
"""Buttons only in a private chat with the bot: in a group anyone could
|
||||
tap them, so taps there are ignored (Telegram's group ids are negative)."""
|
||||
if str(conversation.external_conversation_id).startswith("-"):
|
||||
return None
|
||||
return self.interactive_limits
|
||||
|
||||
async def send_interactive(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
message: InteractiveMessage) -> SendResult:
|
||||
"""The choices as an inline keyboard under the text, one per row."""
|
||||
if message.kind != InteractiveKind.BUTTONS:
|
||||
return SendResult(ok=False, error="Telegram only sends choices as buttons")
|
||||
keyboard = {"inline_keyboard": [[{"text": b.title, "callback_data": b.id}] for b in message.buttons]}
|
||||
return await self._send_message(account, conversation.external_conversation_id, message.body,
|
||||
reply_markup=keyboard)
|
||||
|
||||
async def clear_buttons(self, account: ChannelAccount, chat_id: str, message_id: Optional[str]) -> None:
|
||||
"""Take the keyboard off a message once one of its buttons was tapped.
|
||||
Best effort: a keyboard left in place is only a stale one."""
|
||||
if not message_id:
|
||||
return
|
||||
try:
|
||||
await _call_api(self._bot_token(account), "editMessageReplyMarkup", {
|
||||
"chat_id": chat_id, "message_id": int(message_id),
|
||||
"reply_markup": {"inline_keyboard": []},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Telegram editMessageReplyMarkup failed (non-critical): {e}")
|
||||
|
||||
async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None:
|
||||
try:
|
||||
await _call_api(self._bot_token(account), "sendChatAction", {
|
||||
"chat_id": conversation.external_conversation_id,
|
||||
"action": "typing",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Telegram sendChatAction failed (non-critical): {e}")
|
||||
|
||||
async def request_phone(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
text: str) -> SendResult:
|
||||
# One-time reply keyboard with a share-contact button
|
||||
keyboard = {
|
||||
"keyboard": [[{"text": "📱 Share my phone number", "request_contact": True}]],
|
||||
"resize_keyboard": True,
|
||||
"one_time_keyboard": True,
|
||||
}
|
||||
return await self._send_message(account, conversation.external_conversation_id, text,
|
||||
reply_markup=keyboard)
|
||||
|
||||
async def _send_message(self, account: ChannelAccount, chat_id: str, text: str,
|
||||
reply_markup: dict = None) -> SendResult:
|
||||
try:
|
||||
payload = {"chat_id": chat_id, "text": text}
|
||||
if reply_markup is not None:
|
||||
payload["reply_markup"] = reply_markup
|
||||
data = await _call_api(self._bot_token(account), "sendMessage", payload)
|
||||
if not data.get("ok"):
|
||||
return SendResult(ok=False, error=str(data.get("description") or "sendMessage failed"))
|
||||
return SendResult(ok=True, external_message_id=str(data["result"].get("message_id", "")))
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram send failed: {e}")
|
||||
return SendResult(ok=False, error=str(e))
|
||||
|
||||
def format_outbound(self, markdown: str) -> str:
|
||||
"""Telegram is sent as plain text (no parse_mode) — markdown renders
|
||||
readably and escaping pitfalls are avoided. Enforce the length cap."""
|
||||
return markdown[:MAX_MESSAGE_LENGTH]
|
||||
|
||||
@staticmethod
|
||||
def _bot_token(account: ChannelAccount) -> str:
|
||||
return json.loads(decrypt_api_key(account.encrypted_credentials))["bot_token"]
|
||||
|
||||
|
||||
def _tap(callback: dict) -> Optional[InboundMessage]:
|
||||
"""A tap on one of our buttons, in a private chat with the bot (in a
|
||||
group anyone could tap it). The tap carries only the button's id; its
|
||||
title is read from the keyboard it was on. Dropped when that message is
|
||||
gone or no longer accessible."""
|
||||
message = callback.get("message") or {}
|
||||
chat = message.get("chat") or {}
|
||||
sender = callback.get("from") or {}
|
||||
data = callback.get("data")
|
||||
if not data or not message.get("date") or chat.get("type") != PRIVATE_CHAT or "id" not in chat:
|
||||
return None
|
||||
title = _button_title(message.get("reply_markup") or {}, data)
|
||||
if title is None:
|
||||
return None
|
||||
name = " ".join(filter(None, [sender.get("first_name"), sender.get("last_name")])) or sender.get("username")
|
||||
return InboundMessage(
|
||||
external_account_id="", # resolved from the webhook path, not the payload
|
||||
external_conversation_id=str(chat["id"]),
|
||||
external_user_id=str(sender.get("id", chat["id"])),
|
||||
external_message_id=f"{CALLBACK_ID_PREFIX}{callback['id']}",
|
||||
text=title,
|
||||
profile={"name": name, "username": sender.get("username")},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
interactive=InteractiveReply(type=InteractiveReplyType.BUTTON_REPLY, id=str(data), title=title),
|
||||
reply_to_message_id=str(message.get("message_id", "")) or None,
|
||||
)
|
||||
|
||||
|
||||
def _button_title(reply_markup: dict, data: str) -> Optional[str]:
|
||||
for row in reply_markup.get("inline_keyboard") or []:
|
||||
for button in row:
|
||||
if button.get("callback_data") == data:
|
||||
return button.get("text")
|
||||
return None
|
||||
|
||||
|
||||
register_adapter(TelegramAdapter())
|
||||
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
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 (
|
||||
DeliveryState, InboundMessage, InteractiveKind, InteractiveMessage,
|
||||
InteractiveReply, InteractiveReplyType, InteractiveSection, SendResult, SharedLocation,
|
||||
StatusEvent, WindowStatus,
|
||||
)
|
||||
from app.channels.constants import DEFAULT_TEMPLATE_LANGUAGE
|
||||
from app.channels.interactive_limits import WHATSAPP_LIMITS, InteractiveLimits
|
||||
from app.channels.meta_base import MetaBaseAdapter, graph_post
|
||||
from app.channels.registry import register_adapter
|
||||
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
|
||||
class WhatsAppAdapter(MetaBaseAdapter):
|
||||
channel_type: ClassVar[str] = ChannelType.WHATSAPP.value
|
||||
# Outside the 24h window WhatsApp allows re-opening with an approved template
|
||||
expired_status: ClassVar[WindowStatus] = WindowStatus.TEMPLATE_REQUIRED
|
||||
interactive_limits: ClassVar[Optional[InteractiveLimits]] = WHATSAPP_LIMITS
|
||||
reports_delivery_status: ClassVar[bool] = True
|
||||
max_text_length: ClassVar[Optional[int]] = MAX_MESSAGE_LENGTH
|
||||
|
||||
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||||
"""Normalize a WhatsApp Cloud API webhook.
|
||||
|
||||
Shape: entry[].changes[].value.{metadata,contacts,messages}. Status
|
||||
callbacks (delivered/read) carry no `messages` and yield nothing.
|
||||
"""
|
||||
messages: List[InboundMessage] = []
|
||||
for entry in payload.get("entry", []):
|
||||
for change in entry.get("changes", []):
|
||||
value = change.get("value", {})
|
||||
metadata = value.get("metadata", {})
|
||||
phone_number_id = metadata.get("phone_number_id")
|
||||
if not phone_number_id or not value.get("messages"):
|
||||
continue
|
||||
name_by_wa_id = {
|
||||
c.get("wa_id"): (c.get("profile") or {}).get("name")
|
||||
for c in value.get("contacts", [])
|
||||
}
|
||||
for message in value["messages"]:
|
||||
interactive = self._extract_interactive(message)
|
||||
location = self._extract_location(message)
|
||||
text = self._extract_text(message, interactive, location)
|
||||
if text is None:
|
||||
continue # media types until media support lands
|
||||
wa_id = message.get("from")
|
||||
messages.append(InboundMessage(
|
||||
external_account_id=str(phone_number_id),
|
||||
external_conversation_id=str(wa_id),
|
||||
external_user_id=str(wa_id),
|
||||
external_message_id=str(message.get("id", "")),
|
||||
text=text,
|
||||
# wa_id is the customer's number in E.164-without-plus,
|
||||
# declared verbatim (like the SMS adapters) so the one
|
||||
# normalize_msisdn boundary owns all '+' handling.
|
||||
profile={"name": name_by_wa_id.get(wa_id), "phone": str(wa_id)},
|
||||
timestamp=self._timestamp(message.get("timestamp")),
|
||||
interactive=interactive,
|
||||
location=location,
|
||||
reply_to_message_id=(message.get("context") or {}).get("id"),
|
||||
))
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
def _extract_text(message: dict, interactive: Optional[InteractiveReply],
|
||||
location: Optional[SharedLocation]) -> Optional[str]:
|
||||
if message.get("type") == "text":
|
||||
return (message.get("text") or {}).get("body")
|
||||
# A tap reads as the text the customer saw, so the agent needs no
|
||||
# special handling; the id travels separately on `interactive`. A
|
||||
# tap without a title (never expected) falls back to its id rather
|
||||
# than running a turn on nothing.
|
||||
if interactive is not None:
|
||||
return interactive.title or interactive.id or None
|
||||
if location is not None:
|
||||
return _describe_location(location)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_interactive(message: dict) -> Optional[InteractiveReply]:
|
||||
if message.get("type") == "button":
|
||||
# Template quick-reply button: `payload` is what the template set
|
||||
button = message.get("button") or {}
|
||||
return InteractiveReply(type=InteractiveReplyType.TEMPLATE_BUTTON,
|
||||
id=str(button.get("payload") or ""),
|
||||
title=str(button.get("text") or ""))
|
||||
if message.get("type") == "interactive":
|
||||
interactive = message.get("interactive") or {}
|
||||
for reply_type in (InteractiveReplyType.BUTTON_REPLY, InteractiveReplyType.LIST_REPLY):
|
||||
reply = interactive.get(reply_type.value)
|
||||
if reply:
|
||||
return InteractiveReply(type=reply_type,
|
||||
id=str(reply.get("id") or ""),
|
||||
title=str(reply.get("title") or ""))
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_location(message: dict) -> Optional[SharedLocation]:
|
||||
if message.get("type") != "location":
|
||||
return None
|
||||
location = message.get("location") or {}
|
||||
try:
|
||||
return SharedLocation(latitude=float(location["latitude"]),
|
||||
longitude=float(location["longitude"]),
|
||||
name=location.get("name"),
|
||||
address=location.get("address"))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def parse_status_events(self, payload: dict) -> List[StatusEvent]:
|
||||
"""Delivery receipts: entry[].changes[].value.statuses[]."""
|
||||
events: List[StatusEvent] = []
|
||||
for entry in payload.get("entry", []):
|
||||
for change in entry.get("changes", []):
|
||||
value = change.get("value", {})
|
||||
phone_number_id = (value.get("metadata") or {}).get("phone_number_id")
|
||||
if not phone_number_id:
|
||||
continue
|
||||
for status in value.get("statuses") or []:
|
||||
event = self._status_event(str(phone_number_id), status)
|
||||
if event is not None:
|
||||
events.append(event)
|
||||
return events
|
||||
|
||||
def _status_event(self, phone_number_id: str, status: dict) -> Optional[StatusEvent]:
|
||||
try:
|
||||
state = DeliveryState(status.get("status"))
|
||||
except ValueError:
|
||||
return None # e.g. 'deleted' — not a delivery state we track
|
||||
message_id = status.get("id")
|
||||
if not message_id:
|
||||
return None
|
||||
pricing = status.get("pricing") or {}
|
||||
errors = status.get("errors") or []
|
||||
return StatusEvent(
|
||||
external_account_id=phone_number_id,
|
||||
external_message_id=str(message_id),
|
||||
recipient_id=str(status.get("recipient_id") or ""),
|
||||
status=state,
|
||||
timestamp=self._timestamp(status.get("timestamp")),
|
||||
error=_status_error(errors[0]) if errors else None,
|
||||
error_code=str(errors[0]["code"]) if errors and errors[0].get("code") is not None else None,
|
||||
pricing_category=pricing.get("category"),
|
||||
pricing_type=pricing.get("type"),
|
||||
pricing_model=pricing.get("pricing_model"),
|
||||
billable=pricing.get("billable"),
|
||||
)
|
||||
|
||||
def conversation_state(self, inbound: InboundMessage) -> dict:
|
||||
# send_typing marks a specific message as read, so it needs the wamid of
|
||||
# the message it is replying to.
|
||||
if not inbound.external_message_id:
|
||||
return {}
|
||||
return {"last_inbound_message_id": inbound.external_message_id}
|
||||
|
||||
async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None:
|
||||
"""Show a typing indicator. WhatsApp only offers this as part of marking
|
||||
an inbound message read, so this also blue-ticks that message. Expires
|
||||
after 25s or when the reply is delivered."""
|
||||
message_id = (conversation.extra or {}).get("last_inbound_message_id")
|
||||
if message_id:
|
||||
await self._read_receipt(account, message_id, typing=True)
|
||||
|
||||
async def mark_read(self, account: ChannelAccount, external_message_id: str) -> None:
|
||||
if external_message_id:
|
||||
await self._read_receipt(account, external_message_id, typing=False)
|
||||
|
||||
async def _read_receipt(self, account: ChannelAccount, message_id: str, typing: bool) -> None:
|
||||
payload = {"messaging_product": "whatsapp", "status": "read", "message_id": message_id}
|
||||
if typing:
|
||||
payload["typing_indicator"] = {"type": "text"}
|
||||
try:
|
||||
result = await graph_post(f"{account.external_account_id}/messages",
|
||||
self.access_token(account), payload)
|
||||
if not result.ok:
|
||||
logger.debug(f"WhatsApp read receipt failed (non-critical): {result.error}")
|
||||
except Exception as e:
|
||||
logger.debug(f"WhatsApp read receipt failed (non-critical): {e}")
|
||||
|
||||
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||||
return await graph_post(
|
||||
f"{account.external_account_id}/messages",
|
||||
self.access_token(account),
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": conversation.external_conversation_id,
|
||||
"type": "text",
|
||||
"text": {"preview_url": False, "body": text},
|
||||
},
|
||||
)
|
||||
|
||||
async def send_template(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
template_name: str, language: str = DEFAULT_TEMPLATE_LANGUAGE,
|
||||
components: list = None) -> SendResult:
|
||||
"""Send an approved template message — the only way to reach a customer
|
||||
outside the 24h window."""
|
||||
template: dict = {"name": template_name, "language": {"code": language}}
|
||||
if components:
|
||||
template["components"] = components
|
||||
return await graph_post(
|
||||
f"{account.external_account_id}/messages",
|
||||
self.access_token(account),
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": conversation.external_conversation_id,
|
||||
"type": "template",
|
||||
"template": template,
|
||||
},
|
||||
)
|
||||
|
||||
async def send_interactive(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
message: InteractiveMessage) -> SendResult:
|
||||
return await graph_post(
|
||||
f"{account.external_account_id}/messages",
|
||||
self.access_token(account),
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": conversation.external_conversation_id,
|
||||
"type": "interactive",
|
||||
"interactive": _interactive_payload(message),
|
||||
},
|
||||
)
|
||||
|
||||
def format_outbound(self, markdown: str) -> str:
|
||||
# WhatsApp renders *bold* / _italic_ but agent markdown (**bold**) doesn't
|
||||
# map cleanly; send as-is with the length cap to avoid mangled markup.
|
||||
return markdown[:MAX_MESSAGE_LENGTH]
|
||||
|
||||
|
||||
def _describe_location(location: SharedLocation) -> str:
|
||||
"""A shared pin as text the agent (and the inbox) can read."""
|
||||
place = ", ".join(p for p in (location.name, location.address) if p)
|
||||
coordinates = f"{location.latitude}, {location.longitude}"
|
||||
return f"Shared location: {place} ({coordinates})" if place else f"Shared location: {coordinates}"
|
||||
|
||||
|
||||
def _status_error(error: dict) -> str:
|
||||
details = (error.get("error_data") or {}).get("details")
|
||||
return str(details or error.get("message") or error.get("title") or error.get("code") or "failed")
|
||||
|
||||
|
||||
def _interactive_payload(message: InteractiveMessage) -> dict:
|
||||
"""The Graph `interactive` object for a validated InteractiveMessage."""
|
||||
payload: dict = {"body": {"text": message.body}}
|
||||
if message.header:
|
||||
payload["header"] = {"type": "text", "text": message.header}
|
||||
if message.footer:
|
||||
payload["footer"] = {"text": message.footer}
|
||||
|
||||
if message.kind == InteractiveKind.BUTTONS:
|
||||
payload["type"] = "button"
|
||||
payload["action"] = {"buttons": [
|
||||
{"type": "reply", "reply": {"id": b.id, "title": b.title}} for b in message.buttons
|
||||
]}
|
||||
elif message.kind == InteractiveKind.LIST:
|
||||
payload["type"] = "list"
|
||||
payload["action"] = {
|
||||
"button": message.list_button_label,
|
||||
"sections": [_list_section(s) for s in message.sections],
|
||||
}
|
||||
else:
|
||||
payload["type"] = "cta_url"
|
||||
payload["action"] = {"name": "cta_url", "parameters": {
|
||||
"display_text": message.display_text, "url": message.url,
|
||||
}}
|
||||
return payload
|
||||
|
||||
|
||||
def _list_section(section: InteractiveSection) -> dict:
|
||||
rows = []
|
||||
for row in section.rows:
|
||||
item = {"id": row.id, "title": row.title}
|
||||
if row.description:
|
||||
item["description"] = row.description
|
||||
rows.append(item)
|
||||
out: dict = {"rows": rows}
|
||||
if section.title:
|
||||
out["title"] = section.title
|
||||
return out
|
||||
|
||||
|
||||
register_adapter(WhatsAppAdapter())
|
||||
Reference in new issue
Block a user