Files
replypier/backend/app/channels/app_hooks.py
T
WangShen007 20fef2b9f3 chore: settle product naming across app, widget and deployments
One word everywhere: ReplyPier in UI copy, package and image names, the
replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the
--rp-* design tokens.

- public widget API is window.ReplyPier with replypierId / replypierBaseUrl /
  replypierConfig / replypierToken, loader at webclient/replypier.min.js;
  both bundles rebuilt from source
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime
  config rewritten; the demo config ships no third-party keys
- migration adds the ReplyPier managed model value to the AI model enum
- local compose project, database, role, volume and Docker network carry the
  same name
2026-10-26 00:57:22 +08:00

202 lines
7.1 KiB
Python

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
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,
)