201 lines
7.1 KiB
Python
201 lines
7.1 KiB
Python
"""
|
|||
|
|
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,
|
||
|
|
)
|