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

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

No files matched your search

+30
View File
@@ -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",
]
+201
View File
@@ -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,
)
+267
View File
@@ -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")
+67
View File
@@ -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
+266
View File
@@ -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())
+66
View File
@@ -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())
+200
View File
@@ -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)
+172
View File
@@ -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())
+186
View File
@@ -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())
+457
View File
@@ -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
+53
View File
@@ -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
+66
View File
@@ -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
+366
View File
@@ -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())
+38
View File
@@ -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",
]
+60
View File
@@ -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())
+136
View File
@@ -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},
)
+74
View File
@@ -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())
+88
View File
@@ -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())
+95
View File
@@ -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())
+173
View File
@@ -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())
+104
View File
@@ -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())
+104
View File
@@ -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())
+309
View File
@@ -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())
+308
View File
@@ -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())