Files
replypier/backend/app/services/channel_chat.py
T

532 lines
24 KiB
Python
Raw Normal View History

"""
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)