""" 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. """ from dataclasses import dataclass, replace from typing import Optional from sqlalchemy.orm import Session from app.core.socketio import sio from app.channels import ChannelAdapter, SendResult, get_adapter, WindowStatus from app.channels.app_hooks import OutboundRecord, emit_channel_event, outbound_event from app.channels.base import InteractiveMessage from app.channels.constants import MessageSource from app.channels.interactive import ( DEFAULT_CHOICES_BODY, to_text_fallback, validate_interactive, ) from app.channels.interactive_limits import InteractiveLimits from app.channels.sent_choices import SENT_CHOICES_ATTRIBUTE, encode_sent_choices from app.models.channels import ChannelAccount, ChannelConversation from app.models.session_to_agent import SessionToAgent from app.repositories.channels import ChannelAccountRepository, ChannelConversationRepository from app.repositories.chat import ChatRepository from app.services.business_message import forget_business_message from app.services.channel_status import record_sent from app.core.logger import get_logger logger = get_logger(__name__) WEB_CHANNEL = 'web' @dataclass class DeliveryResult: ok: bool reason: Optional[str] = None # A WhatsApp template message could reopen the conversation can_template: bool = False # The channel's id for the sent message (WhatsApp wamid), for callers that # report it on (the external-app send API) external_message_id: Optional[str] = None @dataclass class _Sent: """What one reply actually went out as.""" result: SendResult # The choices that went with it, and whether they had to go as text choices: Optional[InteractiveMessage] = None as_text: bool = False # A reply too long to carry its choices went out first on its own lead: Optional[SendResult] = None async def deliver_to_customer(db: Session, session_record: SessionToAgent, payload: dict, message_id: Optional[int] = None, interactive: Optional[InteractiveMessage] = None, *, source: MessageSource) -> DeliveryResult: """Route a reply (human or bot) to wherever the customer is. Widget sessions keep the existing Socket.IO emit unchanged; external channel sessions are delivered through their ChannelAdapter. The widget emit also fires for channel sessions so any live dashboard viewers of the session room stay consistent. `message_id` is the stored chat_history row being delivered: the platform's id for the send is recorded on exactly that row, so delivery receipts land on the right message. `interactive` offers choices with the reply (its body is the reply text) — buttons where the channel has them, a numbered list otherwise; what actually went out is recorded on that row. `source` is who wrote the message; channel sends are announced to extensions as `outbound` events with it. """ session_id = str(session_record.session_id) channel = getattr(session_record, 'channel', None) if not isinstance(channel, str) or not channel: channel = WEB_CHANNEL if channel == WEB_CHANNEL: await sio.emit('chat_response', payload, room=session_id, namespace='/widget') return DeliveryResult(ok=True) result = await _deliver_to_channel(db, session_record, channel, payload, message_id, interactive, source) await sio.emit('chat_response', payload, room=session_id, namespace='/widget') return result async def _deliver_to_channel(db: Session, session_record: SessionToAgent, channel: str, payload: dict, message_id: Optional[int], interactive: Optional[InteractiveMessage], source: MessageSource) -> DeliveryResult: session_id = str(session_record.session_id) adapter = get_adapter(channel) if adapter is None: logger.error(f"No adapter registered for channel '{channel}' (session {session_id})") return DeliveryResult(ok=False, reason='unknown_channel') conversation = ChannelConversationRepository(db).get_by_session(session_record.session_id) if conversation is None: logger.error(f"No channel conversation for {channel} session {session_id}") return DeliveryResult(ok=False, reason='no_channel_conversation') account = ChannelAccountRepository(db).get_by_id(conversation.channel_account_id) if account is None or not account.is_active: return DeliveryResult(ok=False, reason='account_inactive') window = adapter.check_delivery_window(conversation) if window is WindowStatus.TEMPLATE_REQUIRED: return DeliveryResult(ok=False, reason='window_expired', can_template=True) if window is WindowStatus.UNDELIVERABLE: return DeliveryResult(ok=False, reason='window_expired') text = payload.get('message') or '' if not text.strip(): # Nothing textual to forward (e.g. attachment-only payloads until # media support lands) — not a failure. return DeliveryResult(ok=True) sent = await _send_reply(adapter, account, conversation, text, interactive) ChannelAccountRepository(db).track_send_result(account, sent.result) if not sent.result.ok: logger.error(f"Failed delivering to {channel} session {session_id}: {sent.result.error}") return DeliveryResult(ok=False, reason=sent.result.error or 'send_failed', can_template=sent.result.can_template) if message_id is not None: await _record_delivery(db, adapter, account, message_id, sent) if source == MessageSource.HUMAN: # A person has taken the conversation from there; the AI no longer # reads it as a reply to the business's last own message forget_business_message(db, conversation) customer_id = str(session_record.customer_id) if session_record.customer_id else None # Each platform message is announced: the reply sent on its own first, # then the one carrying the choices announced = ([(sent.lead, text, None), (sent.result, sent.choices.body, sent.choices)] if sent.lead is not None else [(sent.result, text, sent.choices)]) for result, announced_text, choices in announced: await emit_channel_event(outbound_event( organization_id=account.organization_id, channel_account_id=account.id, session_id=session_id, customer_id=customer_id, record=OutboundRecord(message_id=message_id, external_message_id=result.external_message_id, source=source, text=announced_text, interactive=choices), )) return DeliveryResult(ok=True, external_message_id=sent.result.external_message_id) async def _record_delivery(db: Session, adapter: ChannelAdapter, account: ChannelAccount, message_id: int, sent: _Sent) -> None: """Note on the stored row what went out and under which platform id. Bookkeeping only: the message is already with the customer, so a failure here is logged, never reported as a failed delivery.""" try: if sent.choices is not None: ChatRepository(db).update_message_attributes(message_id, { SENT_CHOICES_ATTRIBUTE: encode_sent_choices( sent.choices, sent.as_text, numbered=bool(adapter.interactive_limits and adapter.interactive_limits.options_in_text)), }) external_message_id = sent.result.external_message_id if adapter.reports_delivery_status and external_message_id: await record_sent(db, message_id, account.id, external_message_id) elif sent.choices is not None and external_message_id: # A tap is matched back to the message that offered it ChatRepository(db).set_external_message_id(message_id, external_message_id) except Exception as e: logger.error(f"Could not record delivery of message {message_id}: {e}") def _shown_length(message: InteractiveMessage, limits: InteractiveLimits) -> int: """How long the message's text is as sent: with the choices written in too where the channel shows them that way.""" return len(to_text_fallback(message) if limits.options_in_text else message.body) async def _send_reply(adapter: ChannelAdapter, account: ChannelAccount, conversation: ChannelConversation, text: str, interactive: Optional[InteractiveMessage]) -> _Sent: """Send the reply, with its choices as native buttons when the channel supports them and they fit; otherwise as a numbered list in the text, so the choices are never silently lost. A reply too long to be an interactive body goes out as text first, then the choices under a short prompt — the choices carry the platform id, as a tap replies to them. """ if interactive is None: result = await adapter.send_text(account, conversation, adapter.format_outbound(text)) return _Sent(result=result) choices = interactive lead = None if adapter.supports_interactive: limits = adapter.interactive_limits if _shown_length(interactive, limits) > limits.body_max: lead = await adapter.send_text(account, conversation, adapter.format_outbound(text)) if not lead.ok: return _Sent(result=lead) choices = replace(interactive, body=DEFAULT_CHOICES_BODY) errors = validate_interactive(choices, limits) if errors: logger.warning(f"Choices not sendable as interactive on {adapter.channel_type}, " f"sending as text: {'; '.join(errors)}") else: result = await adapter.send_interactive(account, conversation, choices) if result.ok: return _Sent(result=result, choices=choices, lead=lead) logger.warning(f"Interactive send failed on {adapter.channel_type}, " f"falling back to text: {result.error}") fallback = to_text_fallback(choices, adapter.max_text_length) result = await adapter.send_text(account, conversation, adapter.format_outbound(fallback)) if not result.ok and lead is not None: # The reply itself reached the customer; only its choices didn't logger.warning(f"Choices undelivered on {adapter.channel_type} after the reply was " f"sent: {result.error}") return _Sent(result=lead) return _Sent(result=result, choices=choices, as_text=True, lead=lead)