feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
69a71aabbb
commit
e0baca15a9
225 files changed
+59337
No files matched your search
@@ -0,0 +1,532 @@
|
||||
"""
|
||||
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)
|
||||
Reference in new issue
Block a user