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
533 lines
24 KiB
Python
533 lines
24 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.
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.agents.chat_agent import ChatAgent
|
|
from app.channels import InboundMessage, ChannelInteraction, get_adapter
|
|
from app.channels.app_hooks import HandledBy
|
|
from app.channels.base import InteractiveMessage
|
|
from app.channels.constants import MessageSource
|
|
from app.channels.interactive import from_options
|
|
from app.channels.interactive_limits import InteractiveLimits
|
|
from app.core.security import decrypt_api_key
|
|
from app.core.logger import get_logger
|
|
from app.database import SessionLocal
|
|
from app.models.ai_config import AIModelType
|
|
from app.models.channels import ChannelAccount
|
|
from app.models.session_to_agent import SessionStatus
|
|
from app.repositories.agent import AgentRepository
|
|
from app.repositories.ai_config import AIConfigRepository
|
|
from app.repositories.chat import ChatRepository
|
|
from app.repositories.customer import CustomerRepository
|
|
from app.utils.phone import normalize_msisdn
|
|
from app.repositories.channels import (
|
|
ChannelAccountRepository,
|
|
ChannelConversationRepository,
|
|
AgentChannelConfigRepository,
|
|
)
|
|
from app.repositories.session_to_agent import SessionToAgentRepository
|
|
from app.services.app_first_routing import announce_inbound, app_claims
|
|
from app.services.chat_notifications import notify_new_channel_chat_once, team_not_yet_notified
|
|
from app.services.human_routing import HUMAN_ONLY_ACK, notify_customer
|
|
from app.services.lead_capture import record_lead_from_response
|
|
from app.services.conversation_token import VERIFIED_SCOPE
|
|
from app.services.agent_rooms import USER_MESSAGE, relay_stored_message
|
|
from app.services.business_message import business_message_context
|
|
from app.services.channel_replies import inbound_attributes, reply_text
|
|
from app.services.message_delivery import DeliveryResult, deliver_to_customer
|
|
|
|
# Optional enterprise message-limit check (same seam as the widget path)
|
|
try:
|
|
from app.enterprise.services.message_limit import check_message_limit
|
|
HAS_ENTERPRISE = True
|
|
except ImportError:
|
|
HAS_ENTERPRISE = False
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
async def process_channel_message(account_id, inbound: InboundMessage) -> None:
|
|
"""Process one inbound customer message from an external channel.
|
|
|
|
Channel-agnostic core shared by every webhook route: resolves the
|
|
customer and session, stores the message, runs the AI agent (or relays
|
|
to the handling human), and delivers the reply through the channel's
|
|
adapter. Runs from a BackgroundTask with its own DB session.
|
|
"""
|
|
db = SessionLocal()
|
|
try:
|
|
account_repo = ChannelAccountRepository(db)
|
|
account = account_repo.get_by_id(account_id)
|
|
if account is None or not account.is_active:
|
|
logger.warning(f"Ignoring message for missing/inactive channel account {account_id}")
|
|
return
|
|
|
|
adapter = get_adapter(account.channel_type)
|
|
|
|
agent_id = AgentChannelConfigRepository(db).get_active_agent_id(account.id)
|
|
if agent_id is None:
|
|
logger.warning(f"No agent configured for {account.channel_type} account {account.id}")
|
|
return
|
|
|
|
org_id = str(account.organization_id)
|
|
customer_id = _get_or_create_customer(db, account, inbound, org_id)
|
|
# If the customer still has a synthesized placeholder name, resolve the
|
|
# real one via the platform API — only then, not on every message.
|
|
if adapter is not None:
|
|
await _enrich_customer_name(db, adapter, account, inbound, customer_id)
|
|
session_record, conversation, is_new_session = _get_or_create_session(
|
|
db, account, inbound, agent_id, customer_id, org_id
|
|
)
|
|
session_id = str(session_record.session_id)
|
|
|
|
# Merge channel-specific per-conversation state (e.g. email threading
|
|
# headers) so outbound replies can use it.
|
|
if adapter is not None:
|
|
state = adapter.conversation_state(inbound)
|
|
if state:
|
|
ChannelConversationRepository(db).merge_extra(conversation, state)
|
|
|
|
# A human-only agent answers nothing — the chat waits in the inbox for
|
|
# someone to pick it up, so it takes the same path as one a human
|
|
# already holds.
|
|
agent = AgentRepository(db).get_agent(agent_id)
|
|
ai_replies_enabled = agent.ai_replies_enabled if agent else True
|
|
# Buttons are the agent's choice (off unless turned on), where the
|
|
# channel can show them
|
|
choice_limits = (adapter.choice_limits_for(conversation)
|
|
if adapter is not None and agent is not None and agent.channel_quick_replies else None)
|
|
|
|
# An installed extension (an external app) may answer first. Its reply
|
|
# can land while we wait, so the customer's message keeps the time it
|
|
# arrived and still sorts before it.
|
|
received_at = datetime.now(timezone.utc)
|
|
if await app_claims(db, account, session_record, customer_id, inbound, received_at):
|
|
stored = _store_customer_message(db, inbound, session_record, agent_id,
|
|
customer_id, org_id, account, created_at=received_at)
|
|
await relay_stored_message(session_record, stored, USER_MESSAGE)
|
|
if adapter is not None:
|
|
await adapter.mark_read(account, inbound.external_message_id)
|
|
await announce_inbound(account, session_record, customer_id, inbound,
|
|
HandledBy.APP, received_at)
|
|
return
|
|
|
|
# From here the team (AI or inbox) is answering
|
|
await notify_new_channel_chat_once(db, session_record, conversation)
|
|
|
|
# Human is handling (or a transfer is pending, or this agent never uses
|
|
# AI): store + relay to the agent dashboard, never run the bot.
|
|
if (session_record.user_id is not None
|
|
or session_record.status == SessionStatus.TRANSFERRED
|
|
or not ai_replies_enabled):
|
|
stored = _store_customer_message(db, inbound, session_record, agent_id,
|
|
customer_id, org_id, account)
|
|
await relay_stored_message(session_record, stored, USER_MESSAGE)
|
|
await announce_inbound(account, session_record, customer_id, inbound,
|
|
HandledBy.HUMAN, received_at)
|
|
|
|
# Nothing will answer this on its own, so acknowledge once — on a
|
|
# channel an unanswered message reads as a dead number. Only on the
|
|
# opening message of a brand-new conversation.
|
|
if is_new_session and not ai_replies_enabled:
|
|
await notify_customer(db, session_record, HUMAN_ONLY_ACK,
|
|
channels_only=False)
|
|
return
|
|
|
|
await announce_inbound(account, session_record, customer_id, inbound,
|
|
HandledBy.AI, received_at)
|
|
|
|
ai_config = AIConfigRepository(db).get_active_config(account.organization_id)
|
|
if ai_config is None:
|
|
logger.error(f"No AI config for org {org_id}; dropping {account.channel_type} message")
|
|
return
|
|
|
|
if not await _within_message_limit(db, org_id, ai_config):
|
|
await _send_via_adapter(
|
|
db, session_record,
|
|
"Sorry, this assistant has reached its message limit. Please try again later."
|
|
)
|
|
return
|
|
|
|
# Show a typing indicator while the agent composes its reply
|
|
if adapter is not None:
|
|
await adapter.send_typing(account, conversation)
|
|
|
|
response, bot_message_id = await _run_chat_agent(
|
|
db, account, ai_config, inbound,
|
|
org_id=org_id, agent_id=str(agent_id),
|
|
customer_id=customer_id, session_id=session_id,
|
|
conversation=conversation,
|
|
choice_limits=choice_limits,
|
|
)
|
|
if response is None:
|
|
# The agent hard-failed. Still send a reply so a typing indicator /
|
|
# placeholder (Slack, LINE) resolves instead of dangling.
|
|
await _deliver(db, session_record, {
|
|
'message': "Sorry, I hit an error handling that — please try again.",
|
|
'type': 'chat_response',
|
|
}, source=MessageSource.SYSTEM)
|
|
return
|
|
|
|
record_lead_from_response(
|
|
db, response,
|
|
organization_id=account.organization_id,
|
|
agent_id=agent_id,
|
|
customer_id=customer_id,
|
|
session_id=session_id,
|
|
channel=account.channel_type,
|
|
# The platform vouches for who is messaging.
|
|
scope=VERIFIED_SCOPE,
|
|
)
|
|
|
|
delivery = await _deliver(db, session_record, {
|
|
'message': response.message,
|
|
'type': 'chat_response',
|
|
'transfer_to_human': getattr(response, 'transfer_to_human', False),
|
|
'end_chat': getattr(response, 'end_chat', False),
|
|
}, source=MessageSource.AI, message_id=bot_message_id,
|
|
interactive=_offered_choices(response, choice_limits))
|
|
if not delivery.ok and bot_message_id is not None:
|
|
ChatRepository(db).mark_delivery_failed(bot_message_id, delivery.reason)
|
|
|
|
await _send_channel_prompts(db, adapter, account, conversation, response)
|
|
except Exception as e:
|
|
logger.error(f"Error processing channel message: {e}", exc_info=True)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
async def _send_channel_prompts(db, adapter, account, conversation, response) -> None:
|
|
"""After the reply, surface channel-native prompts. Rating is deliberately
|
|
NOT asked on external channels — it is a web-widget-only feature; here we
|
|
only request a phone number when the agent asks for contact."""
|
|
if adapter is None:
|
|
return
|
|
try:
|
|
if getattr(response, 'request_contact', False):
|
|
await adapter.request_phone(
|
|
account, conversation,
|
|
"To help us follow up, tap below to share your phone number.")
|
|
except Exception as e:
|
|
logger.error(f"Failed sending channel prompt: {e}")
|
|
|
|
|
|
async def process_channel_interaction(account_id, interaction: ChannelInteraction) -> None:
|
|
"""Handle a non-message interaction (a shared phone number) from a channel.
|
|
Runs from a BackgroundTask with its own DB session."""
|
|
db = SessionLocal()
|
|
try:
|
|
account = ChannelAccountRepository(db).get_by_id(account_id)
|
|
if account is None or not account.is_active:
|
|
return
|
|
conversation = ChannelConversationRepository(db).get_latest(
|
|
account.id, interaction.external_conversation_id)
|
|
if conversation is None:
|
|
return
|
|
|
|
if interaction.type == "contact":
|
|
_handle_contact(db, conversation, interaction)
|
|
except Exception as e:
|
|
logger.error(f"Error processing channel interaction: {e}", exc_info=True)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _handle_contact(db, conversation, interaction: ChannelInteraction) -> None:
|
|
"""Store a shared phone number on the customer record."""
|
|
if not interaction.phone or conversation.customer_id is None:
|
|
return
|
|
from app.models.customer import Customer
|
|
customer = db.query(Customer).filter(Customer.id == conversation.customer_id).first()
|
|
if customer is None:
|
|
return
|
|
meta = dict(customer.meta_data or {})
|
|
meta["phone"] = interaction.phone
|
|
customer.meta_data = meta
|
|
# Also promote to the identity column (set-if-absent, conflict-guarded) so
|
|
# the person becomes phone-addressable; meta_data keeps the raw value for
|
|
# anything already reading it there. msisdn-lenient because the number is
|
|
# platform-relayed (Telegram sends it with or without '+'), and one commit
|
|
# for both writes — separate transactions left a crash window where the
|
|
# two stores disagreed.
|
|
phone = normalize_msisdn(interaction.phone)
|
|
if phone:
|
|
CustomerRepository(db).set_phone_if_absent(customer, phone)
|
|
db.commit()
|
|
logger.info(f"Stored shared phone for customer {conversation.customer_id}")
|
|
|
|
|
|
def _get_or_create_customer(db: Session, account: ChannelAccount,
|
|
inbound: InboundMessage, org_id: str) -> str:
|
|
"""Resolve the platform user to a Customer.
|
|
|
|
When the channel already gives us the customer's real email (e.g. the
|
|
email channel), use it verbatim so the same person is unified across
|
|
channels and the widget. Otherwise synthesize a stable per-channel address.
|
|
|
|
Phone is the second identity key: adapters that truthfully know the
|
|
customer's number (WhatsApp, SMS) declare it in profile['phone'], and the
|
|
repository resolves by phone before email — which is what unifies one human
|
|
across phone-bearing channels and outbound sends.
|
|
"""
|
|
real_email = (inbound.profile or {}).get('email')
|
|
real_name = (inbound.profile or {}).get('name')
|
|
if real_email and '@' in real_email:
|
|
channel_email = real_email.lower()
|
|
else:
|
|
channel_email = f"{inbound.external_user_id}@{account.channel_type}.channel"
|
|
customer = CustomerRepository(db).get_or_create_customer(
|
|
email=channel_email,
|
|
organization_id=uuid.UUID(org_id),
|
|
full_name=real_name or placeholder_name(account.channel_type,
|
|
inbound.external_user_id),
|
|
# msisdn-lenient: adapters supply platform ids (wa_id, SMS sender),
|
|
# which are E.164-without-plus — trusted in a way typed digits aren't.
|
|
phone=normalize_msisdn((inbound.profile or {}).get('phone')),
|
|
)
|
|
# Upgrade an existing placeholder name once we've resolved the real one
|
|
# (get_or_create doesn't update an existing customer's name).
|
|
if real_name and _is_placeholder_name(customer.full_name, account.channel_type):
|
|
try:
|
|
customer.full_name = real_name
|
|
db.commit()
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Failed to update customer name: {e}")
|
|
return str(customer.id)
|
|
|
|
|
|
def placeholder_name(channel_type: str, external_user_id: str) -> str:
|
|
"""The stand-in name for a channel person we don't know yet.
|
|
|
|
Paired with _is_placeholder_name deliberately: one builds the string, the
|
|
other recognises it, and the two only work if they agree exactly. Spelling
|
|
either out a second time somewhere else (an outbound send, say) makes them
|
|
agree by coincidence — and the day the format changes, the detector
|
|
silently stops matching and every real name upgrade stops happening.
|
|
"""
|
|
return f"{channel_type.capitalize()} user {external_user_id[:8]}"
|
|
|
|
|
|
def _is_placeholder_name(name: str, channel_type: str) -> bool:
|
|
"""True when the stored name is the synthesized '<Channel> user xxxx' or empty."""
|
|
if not name:
|
|
return True
|
|
return name.startswith(f"{channel_type.capitalize()} user ")
|
|
|
|
|
|
async def _enrich_customer_name(db: Session, adapter, account: ChannelAccount,
|
|
inbound: InboundMessage, customer_id: str) -> None:
|
|
"""Resolve the customer's real name via the platform API, but only while
|
|
the stored name is still a placeholder.
|
|
|
|
Channels like Slack send just a user id in the webhook, so we look the name
|
|
up via e.g. users.info. Gating on the placeholder means we make that network
|
|
call once — for a new/unresolved customer — not on every inbound message.
|
|
"""
|
|
customer = CustomerRepository(db).get_by_id(uuid.UUID(customer_id))
|
|
if customer is None or not _is_placeholder_name(customer.full_name, account.channel_type):
|
|
return
|
|
try:
|
|
enrichment = await adapter.fetch_profile(account, inbound.external_user_id)
|
|
except Exception as e:
|
|
logger.debug(f"Profile enrichment failed (non-critical): {e}")
|
|
return
|
|
name = (enrichment or {}).get('name')
|
|
if not name:
|
|
return
|
|
try:
|
|
customer.full_name = name
|
|
db.commit()
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Failed to update customer name: {e}")
|
|
|
|
|
|
def _agent_changed(session_record, agent_id) -> bool:
|
|
"""Whether this channel now answers with a different agent than the one the
|
|
open session was started with.
|
|
|
|
A session is bound to one agent: its stored history, and the AI runtime's
|
|
own memory for that session id, belong to that agent. Carrying it over to a
|
|
newly configured agent makes the new agent answer out of the previous one's
|
|
retrieved knowledge, and splits the inbox list in two — the conversations
|
|
query groups by agent *and* session, while the detail view keys on session
|
|
alone, so the same thread appears twice and neither row opens the other.
|
|
|
|
A missing session likewise needs a new one. A human-handled or
|
|
transferred chat is left alone: rotating it would drop the agent out of a
|
|
live conversation mid-reply.
|
|
"""
|
|
if session_record is None:
|
|
return True
|
|
if session_record.user_id is not None:
|
|
return False
|
|
if session_record.status == SessionStatus.TRANSFERRED:
|
|
return False
|
|
return str(session_record.agent_id) != str(agent_id)
|
|
|
|
|
|
def _get_or_create_session(db: Session, account: ChannelAccount, inbound: InboundMessage,
|
|
agent_id, customer_id: str, org_id: str):
|
|
"""Find the open session for this platform conversation or start a new one.
|
|
|
|
Returns (session, conversation, is_new) — callers use is_new to notify the
|
|
team only when a conversation actually starts.
|
|
"""
|
|
conv_repo = ChannelConversationRepository(db)
|
|
session_repo = SessionToAgentRepository(db)
|
|
|
|
conversation = conv_repo.get_active(account.id, inbound.external_conversation_id)
|
|
if conversation is not None:
|
|
session_record = session_repo.get_session(conversation.session_id)
|
|
if not _agent_changed(session_record, agent_id):
|
|
conv_repo.touch_inbound(conversation)
|
|
return session_record, conversation, False
|
|
# Retire the thread so the newly configured agent starts clean. Closing
|
|
# the session is what makes get_active skip it, so the create below
|
|
# opens a fresh conversation for the same platform thread.
|
|
logger.info(
|
|
f"Agent changed for {account.channel_type} account {account.id}; "
|
|
f"starting a new session for conversation {inbound.external_conversation_id}")
|
|
session_repo.close_session(conversation.session_id)
|
|
|
|
session_id = str(uuid.uuid4())
|
|
session_record = session_repo.create_session(
|
|
session_id=session_id,
|
|
agent_id=str(agent_id),
|
|
customer_id=customer_id,
|
|
organization_id=org_id,
|
|
channel=account.channel_type,
|
|
)
|
|
conversation = conv_repo.create(
|
|
channel_account_id=account.id,
|
|
channel_type=account.channel_type,
|
|
external_conversation_id=inbound.external_conversation_id,
|
|
external_user_id=inbound.external_user_id,
|
|
session_id=uuid.UUID(session_id),
|
|
organization_id=account.organization_id,
|
|
agent_id=agent_id,
|
|
customer_id=uuid.UUID(customer_id),
|
|
extra=team_not_yet_notified(),
|
|
)
|
|
return session_record, conversation, True
|
|
|
|
|
|
def _store_customer_message(db: Session, inbound: InboundMessage, session_record,
|
|
agent_id, customer_id: str, org_id: str,
|
|
account: ChannelAccount, created_at: Optional[datetime] = None):
|
|
"""Persist a customer message on the paths the AI doesn't answer (the AI
|
|
path persists inside ChatAgent.get_response). Returns the stored row for
|
|
the relay."""
|
|
message = {
|
|
"message": reply_text(db, account.id, inbound),
|
|
"message_type": "user",
|
|
"session_id": str(session_record.session_id),
|
|
"organization_id": org_id,
|
|
"agent_id": str(agent_id),
|
|
"customer_id": customer_id,
|
|
"attributes": inbound_attributes(inbound, account.channel_type),
|
|
}
|
|
if created_at is not None:
|
|
message["created_at"] = created_at
|
|
return ChatRepository(db).create_message(message)
|
|
|
|
|
|
async def _within_message_limit(db: Session, org_id: str, ai_config) -> bool:
|
|
"""Enterprise subscription limit; permissive on any failure so the
|
|
community edition and enterprise errors never block conversations."""
|
|
if not (HAS_ENTERPRISE and ai_config.model_type == AIModelType.REPLYPIER):
|
|
return True
|
|
try:
|
|
return await check_message_limit(db, org_id, None, None)
|
|
except Exception as e:
|
|
logger.error(f"Message limit check failed (allowing message): {e}")
|
|
return True
|
|
|
|
|
|
async def _run_chat_agent(db: Session, account: ChannelAccount, ai_config,
|
|
inbound: InboundMessage, org_id: str, agent_id: str,
|
|
customer_id: str, session_id: str, conversation=None,
|
|
choice_limits: Optional[InteractiveLimits] = None):
|
|
"""Run the AI agent for one turn. Workflow-enabled agents fall back to the
|
|
plain agent on external channels (workflow forms are widget-only).
|
|
|
|
Returns (response, id of the stored reply row); response is None when the
|
|
agent hard-failed."""
|
|
chat_agent = await ChatAgent.create_async(
|
|
api_key=decrypt_api_key(ai_config.encrypted_api_key),
|
|
model_name=ai_config.model_name,
|
|
model_type=ai_config.model_type.value if hasattr(ai_config.model_type, 'value') else ai_config.model_type,
|
|
base_url=(ai_config.settings or {}).get('base_url'),
|
|
org_id=org_id,
|
|
agent_id=agent_id,
|
|
customer_id=customer_id,
|
|
session_id=session_id,
|
|
channel=account.channel_type,
|
|
extra_context=business_message_context(conversation),
|
|
choice_limits=choice_limits,
|
|
)
|
|
try:
|
|
response = await chat_agent.get_response(
|
|
message=reply_text(db, account.id, inbound),
|
|
session_id=session_id,
|
|
org_id=org_id,
|
|
agent_id=agent_id,
|
|
customer_id=customer_id,
|
|
user_attributes=inbound_attributes(inbound, account.channel_type),
|
|
)
|
|
return response, chat_agent.last_bot_message_id
|
|
except Exception as e:
|
|
logger.error(f"Chat agent error on {account.channel_type} session {session_id}: {e}")
|
|
return None, None
|
|
finally:
|
|
await chat_agent.safe_cleanup_mcp_tools()
|
|
|
|
|
|
def _offered_choices(response, limits: Optional[InteractiveLimits]) -> Optional[InteractiveMessage]:
|
|
"""The agent's choices as the interactive message to send with its reply."""
|
|
options = getattr(response, 'quick_replies', None)
|
|
return from_options(response.message, options, limits) if options and limits is not None else None
|
|
|
|
|
|
async def _deliver(db: Session, session_record, payload: dict, *, source: MessageSource,
|
|
message_id: Optional[int] = None,
|
|
interactive: Optional[InteractiveMessage] = None) -> DeliveryResult:
|
|
"""Deliver a reply and log any failure.
|
|
|
|
Unlike the widget path there is no agent socket to notify here, so a failed
|
|
send would otherwise be silent: the customer gets nothing and nothing says
|
|
so. Callers that have a stored message act further on the result.
|
|
"""
|
|
result = await deliver_to_customer(db, session_record, payload, message_id=message_id,
|
|
interactive=interactive, source=source)
|
|
if not result.ok:
|
|
logger.warning(
|
|
f"Undelivered reply on {getattr(session_record, 'channel', 'unknown')} "
|
|
f"session {session_record.session_id}: {result.reason}")
|
|
return result
|
|
|
|
|
|
async def _send_via_adapter(db: Session, session_record, text: str) -> None:
|
|
"""Send a plain service message to the customer's channel."""
|
|
await _deliver(db, session_record, {'message': text, 'type': 'chat_response'},
|
|
source=MessageSource.SYSTEM)
|