715 lines
30 KiB
Python
715 lines
30 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.
|
|
"""
|
|
|
|
from sqlalchemy.orm import Session
|
|
from typing import Any, Dict, Iterable, List, Optional
|
|
from app.models.channels import ChannelConversation
|
|
from app.models.chat_history import ChatHistory
|
|
from app.models.customer import Customer
|
|
from uuid import UUID
|
|
from sqlalchemy import func, or_, select, text, and_
|
|
from sqlalchemy.sql import case
|
|
from app.models.agent import Agent
|
|
from app.models.session_to_agent import SessionToAgent
|
|
from app.repositories.channels import ChannelConversationRepository
|
|
from app.core.logger import get_logger
|
|
from app.channels.constants import is_widget_channel, MessageSource
|
|
from app.channels.sent_choices import SENT_CHOICES_ATTRIBUTE, decode_sent_choices
|
|
from app.models.user import User
|
|
from app.repositories.customer import CustomerRepository
|
|
from sqlalchemy.orm import joinedload
|
|
from sqlalchemy.orm.attributes import set_committed_value
|
|
from datetime import datetime
|
|
from pydantic import BaseModel
|
|
from app.services.file_storage import resolve_public_url
|
|
from app.services.sentiment import analyze_sentiment, compute_session_sentiment
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
def _readable_attributes(attributes: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
"""A message's attributes for the inbox: the encrypted record of the
|
|
choices it was sent with, decrypted."""
|
|
if not attributes or SENT_CHOICES_ATTRIBUTE not in attributes:
|
|
return attributes
|
|
return {**attributes,
|
|
SENT_CHOICES_ATTRIBUTE: decode_sent_choices(attributes[SENT_CHOICES_ATTRIBUTE])}
|
|
|
|
|
|
class ChatRepository:
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
|
|
def has_customer_messages(self, session_id: UUID | str) -> bool:
|
|
"""Whether the customer has said anything in this session yet.
|
|
|
|
The widget opens its session on socket connect, before the visitor
|
|
types, so this is what distinguishes a real new chat from someone
|
|
merely opening the chat window.
|
|
"""
|
|
if isinstance(session_id, str):
|
|
session_id = UUID(session_id)
|
|
return self.db.query(ChatHistory.id).filter(
|
|
ChatHistory.session_id == session_id,
|
|
ChatHistory.message_type == 'user'
|
|
).first() is not None
|
|
|
|
def get_message_count_for_period(
|
|
self,
|
|
org_id: UUID | str,
|
|
start_date: datetime,
|
|
end_date: datetime
|
|
) -> int:
|
|
"""
|
|
Get the total number of bot messages for an organization within a specific date range.
|
|
Only counts messages with message_type='bot', and not ones an external
|
|
app sent (attributes.source == 'app'): they cost no AI.
|
|
|
|
Args:
|
|
org_id: Organization ID
|
|
start_date: Start date of the period
|
|
end_date: End date of the period
|
|
|
|
Returns:
|
|
int: Total number of bot messages in the period
|
|
"""
|
|
if isinstance(org_id, str):
|
|
org_id = UUID(org_id)
|
|
|
|
try:
|
|
# Build filter conditions
|
|
source = ChatHistory.attributes['source'].as_string()
|
|
conditions = [
|
|
ChatHistory.organization_id == org_id,
|
|
ChatHistory.message_type == 'bot', # Only count bot messages
|
|
# NULL-safe: nearly every row has no source at all
|
|
or_(source.is_(None), source != MessageSource.APP.value),
|
|
]
|
|
|
|
# Add date range conditions if they are not None
|
|
if start_date is not None:
|
|
conditions.append(ChatHistory.created_at >= start_date)
|
|
if end_date is not None:
|
|
conditions.append(ChatHistory.created_at <= end_date)
|
|
|
|
count = self.db.query(func.count(ChatHistory.id))\
|
|
.filter(and_(*conditions))\
|
|
.scalar()
|
|
return count or 0
|
|
except Exception as e:
|
|
logger.error(f"Error getting message count: {str(e)}")
|
|
return 0
|
|
|
|
def create_message(self, message_data: Dict[str, Any]) -> ChatHistory:
|
|
"""Create a new chat message."""
|
|
try:
|
|
# Convert any Pydantic models in attributes to dict
|
|
if 'attributes' in message_data:
|
|
attributes = message_data['attributes']
|
|
if 'shopify_output' in attributes and attributes['shopify_output'] is not None:
|
|
if isinstance(attributes['shopify_output'], BaseModel):
|
|
attributes['shopify_output'] = attributes['shopify_output'].dict()
|
|
elif isinstance(attributes['shopify_output'], dict):
|
|
# If it's already a dict, ensure all nested objects are serialized
|
|
if 'products' in attributes['shopify_output']:
|
|
products = attributes['shopify_output']['products']
|
|
attributes['shopify_output']['products'] = [
|
|
p.dict() if isinstance(p, BaseModel) else p
|
|
for p in products
|
|
]
|
|
|
|
# Convert string UUIDs to UUID objects
|
|
for field in ['organization_id', 'user_id', 'customer_id', 'agent_id', 'session_id']:
|
|
if field in message_data and message_data[field] is not None:
|
|
if isinstance(message_data[field], str):
|
|
message_data[field] = UUID(message_data[field])
|
|
|
|
# Analyze sentiment for customer messages ('user' type)
|
|
if message_data.get('message_type') == 'user' and message_data.get('message'):
|
|
try:
|
|
label, score = analyze_sentiment(message_data['message'])
|
|
message_data['sentiment_label'] = label
|
|
message_data['sentiment_score'] = score
|
|
except Exception as e:
|
|
logger.error(f"Error analyzing message sentiment: {str(e)}")
|
|
|
|
message = ChatHistory(**message_data)
|
|
self.db.add(message)
|
|
self.db.commit()
|
|
self.db.refresh(message)
|
|
|
|
# Update session-level sentiment after saving a customer message
|
|
if message_data.get('message_type') == 'user' and message_data.get('session_id'):
|
|
try:
|
|
self._update_session_sentiment(message_data['session_id'])
|
|
except Exception as e:
|
|
logger.error(f"Error updating session sentiment: {str(e)}")
|
|
|
|
return message
|
|
except Exception as e:
|
|
logger.error(f"Error creating message: {str(e)}")
|
|
self.db.rollback()
|
|
raise
|
|
|
|
def update_message_attributes(self, message_id: int, attributes: Dict[str, Any]) -> None:
|
|
"""Merge keys into a message's attributes JSON (e.g. delivery_status)."""
|
|
try:
|
|
message = self.db.query(ChatHistory).filter(ChatHistory.id == message_id).first()
|
|
if not message:
|
|
return
|
|
message.attributes = {**(message.attributes or {}), **attributes}
|
|
self.db.commit()
|
|
except Exception as e:
|
|
logger.error(f"Error updating message attributes: {str(e)}")
|
|
self.db.rollback()
|
|
|
|
def mark_delivery_failed(self, message_id: int, reason: Optional[str]) -> None:
|
|
"""Flag a stored message as never delivered to the customer.
|
|
|
|
Sole writer of delivery_status, which therefore only ever holds a
|
|
failure reason — the inbox treats its presence as "not delivered".
|
|
"""
|
|
self.update_message_attributes(message_id, {'delivery_status': reason or 'failed'})
|
|
|
|
def set_external_message_id(self, message_id: int, external_message_id: str) -> Optional[ChatHistory]:
|
|
"""Record the platform's id for a message we sent, so its delivery
|
|
receipts and taps on its choices can find it. Returns the row, or None
|
|
if it is gone. Some platforms (Telegram) number messages per chat:
|
|
look those up within the chat (in_channel_chat_by_external_message_id),
|
|
never organization-wide."""
|
|
try:
|
|
message = self.db.query(ChatHistory).filter(ChatHistory.id == message_id).first()
|
|
if message is None:
|
|
return None
|
|
message.external_message_id = external_message_id
|
|
self.db.commit()
|
|
return message
|
|
except Exception as e:
|
|
logger.error(f"Error setting external message id: {str(e)}")
|
|
self.db.rollback()
|
|
return None
|
|
|
|
def get_by_external_message_id(self, organization_id: UUID | str,
|
|
external_message_id: str) -> Optional[ChatHistory]:
|
|
"""The message a platform id belongs to, within one organization —
|
|
a receipt can never touch another tenant's row."""
|
|
if isinstance(organization_id, str):
|
|
organization_id = UUID(organization_id)
|
|
return (
|
|
self.db.query(ChatHistory)
|
|
.filter(
|
|
ChatHistory.organization_id == organization_id,
|
|
ChatHistory.external_message_id == external_message_id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
def in_channel_chat_by_external_message_id(self, channel_account_id: UUID, external_conversation_id: str,
|
|
external_message_id: str) -> Optional[ChatHistory]:
|
|
"""The message a platform id belongs to, within one customer's chat on
|
|
one channel account — across its sessions (a chat that closed and
|
|
reopened is a new session), and never another chat's: some platforms
|
|
number messages per chat, not globally."""
|
|
return (
|
|
self._in_channel_chat(channel_account_id, external_conversation_id)
|
|
.filter(ChatHistory.external_message_id == external_message_id)
|
|
.first()
|
|
)
|
|
|
|
def latest_replies_in_channel_chat(self, channel_account_id: UUID, external_conversation_id: str,
|
|
limit: int) -> List[ChatHistory]:
|
|
"""That chat's most recent replies to the customer, the AI's or a
|
|
person's, newest first."""
|
|
return (
|
|
self._in_channel_chat(channel_account_id, external_conversation_id)
|
|
.filter(ChatHistory.message_type.in_(('bot', 'agent')))
|
|
.order_by(ChatHistory.created_at.desc(), ChatHistory.id.desc())
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
|
|
def _in_channel_chat(self, channel_account_id: UUID, external_conversation_id: str):
|
|
return (
|
|
self.db.query(ChatHistory)
|
|
.join(ChannelConversation, ChannelConversation.session_id == ChatHistory.session_id)
|
|
.filter(ChannelConversation.channel_account_id == channel_account_id,
|
|
ChannelConversation.external_conversation_id == external_conversation_id)
|
|
)
|
|
|
|
def advance_channel_status(self, message_id: int, status: str, at: datetime,
|
|
replaceable: Iterable[str]) -> bool:
|
|
"""Set a message's delivery receipt only if it currently has none or
|
|
one of `replaceable` — a single conditional UPDATE, so two receipts
|
|
racing on different workers can't overwrite a newer one with an older.
|
|
Returns whether the row changed."""
|
|
try:
|
|
changed = (
|
|
self.db.query(ChatHistory)
|
|
.filter(
|
|
ChatHistory.id == message_id,
|
|
or_(ChatHistory.channel_status.is_(None),
|
|
ChatHistory.channel_status.in_(list(replaceable))),
|
|
)
|
|
.update({ChatHistory.channel_status: status,
|
|
ChatHistory.channel_status_at: at},
|
|
synchronize_session=False)
|
|
)
|
|
self.db.commit()
|
|
return changed > 0
|
|
except Exception as e:
|
|
logger.error(f"Error setting channel status: {str(e)}")
|
|
self.db.rollback()
|
|
return False
|
|
|
|
def _channel_account_id(self, channel: Optional[str], session_id) -> Optional[str]:
|
|
"""Which connected account a channel conversation belongs to, so the
|
|
inbox can call account-scoped endpoints (e.g. sending a template).
|
|
|
|
Looked up separately rather than joined into the chat-detail query:
|
|
session_id is not unique on channel_conversations, so an outer join
|
|
would multiply that query's groups. Only fires for channel sessions,
|
|
and get_chat_detail fetches one session at a time.
|
|
"""
|
|
if is_widget_channel(channel):
|
|
return None
|
|
conversation = ChannelConversationRepository(self.db).get_by_session(session_id)
|
|
return str(conversation.channel_account_id) if conversation else None
|
|
|
|
def _update_session_sentiment(self, session_id) -> None:
|
|
"""Recompute and update the overall session sentiment from customer messages."""
|
|
try:
|
|
if isinstance(session_id, str):
|
|
session_id = UUID(session_id)
|
|
|
|
# Get all customer message sentiments for this session
|
|
messages = self.db.query(
|
|
ChatHistory.sentiment_score,
|
|
ChatHistory.sentiment_label
|
|
).filter(
|
|
ChatHistory.session_id == session_id,
|
|
ChatHistory.message_type == 'user',
|
|
ChatHistory.sentiment_score.isnot(None)
|
|
).all()
|
|
|
|
scores = [m.sentiment_score for m in messages]
|
|
labels = [m.sentiment_label for m in messages]
|
|
|
|
overall_label, overall_score = compute_session_sentiment(scores, labels)
|
|
|
|
if overall_label is not None:
|
|
session = self.db.query(SessionToAgent).filter(
|
|
SessionToAgent.session_id == session_id
|
|
).first()
|
|
if session:
|
|
session.sentiment_label = overall_label
|
|
session.sentiment_score = overall_score
|
|
self.db.commit()
|
|
except Exception as e:
|
|
logger.error(f"Error updating session sentiment: {str(e)}")
|
|
self.db.rollback()
|
|
|
|
async def get_session_history(self, session_id: str | UUID) -> List[ChatHistory]:
|
|
"""Get chat history for a session with joined relationships"""
|
|
if isinstance(session_id, str):
|
|
session_id = UUID(session_id)
|
|
|
|
messages = (
|
|
self.db.query(ChatHistory)
|
|
.options(
|
|
joinedload(ChatHistory.user),
|
|
joinedload(ChatHistory.agent),
|
|
joinedload(ChatHistory.attachments)
|
|
)
|
|
.filter(ChatHistory.session_id == session_id)
|
|
# id breaks ties — see get_last_messages.
|
|
.order_by(ChatHistory.created_at.asc(), ChatHistory.id.asc())
|
|
.all()
|
|
)
|
|
|
|
# Hand out signed URLs. set_committed_value, not assignment: a plain
|
|
# assignment marks the row dirty, and the next commit on this session
|
|
# would store a link that expires in place of the file's real path.
|
|
for message in messages:
|
|
for attachment in message.attachments or []:
|
|
if attachment.file_url:
|
|
set_committed_value(
|
|
attachment, "file_url", await resolve_public_url(attachment.file_url)
|
|
)
|
|
|
|
return messages
|
|
|
|
def get_last_messages(self, session_ids: List[UUID]) -> Dict[UUID, str]:
|
|
"""Map each session to its most recent message, decrypted.
|
|
|
|
One query for the whole page rather than one per session, and the single
|
|
definition of "most recent" — created_at is transaction time, so messages
|
|
written together share it exactly and id has to break the tie.
|
|
"""
|
|
if not session_ids:
|
|
return {}
|
|
|
|
ranked = (
|
|
select(
|
|
ChatHistory.session_id,
|
|
ChatHistory.message,
|
|
func.row_number().over(
|
|
partition_by=ChatHistory.session_id,
|
|
order_by=(ChatHistory.created_at.desc(), ChatHistory.id.desc()),
|
|
).label('rank')
|
|
)
|
|
.where(ChatHistory.session_id.in_(session_ids))
|
|
.subquery()
|
|
)
|
|
|
|
rows = self.db.execute(
|
|
select(ranked.c.session_id, ranked.c.message).where(ranked.c.rank == 1)
|
|
).all()
|
|
return {session_id: message for session_id, message in rows}
|
|
|
|
def get_user_history(self, user_id: str | UUID) -> List[ChatHistory]:
|
|
"""Get chat history for a user"""
|
|
if isinstance(user_id, str):
|
|
user_id = UUID(user_id)
|
|
|
|
return self.db.query(ChatHistory).filter(
|
|
ChatHistory.user_id == user_id
|
|
).order_by(ChatHistory.created_at.desc()).all()
|
|
|
|
def get_recent_chats(
|
|
self,
|
|
skip: int = 0,
|
|
limit: int = 20,
|
|
agent_id: Optional[str | UUID] = None,
|
|
status: Optional[str] = None,
|
|
user_id: Optional[str | UUID] = None,
|
|
user_groups: Optional[List[str]] = None,
|
|
include_unassigned: bool = False,
|
|
organization_id: Optional[str | UUID] = None,
|
|
user_name: Optional[str] = None,
|
|
filter_user_id: Optional[str | UUID] = None,
|
|
customer_email: Optional[str] = None,
|
|
date_from: Optional[datetime] = None,
|
|
date_to: Optional[datetime] = None
|
|
) -> List[dict]:
|
|
"""Get recent chat overviews grouped by conversation"""
|
|
# Convert string IDs to UUID if needed
|
|
if agent_id and isinstance(agent_id, str):
|
|
agent_id = UUID(agent_id)
|
|
if user_id and isinstance(user_id, str):
|
|
user_id = UUID(user_id)
|
|
if organization_id and isinstance(organization_id, str):
|
|
organization_id = UUID(organization_id)
|
|
if user_groups:
|
|
user_groups = [UUID(g) if isinstance(g, str) else g for g in user_groups]
|
|
|
|
query = self.db.query(
|
|
Customer.id.label('customer_id'),
|
|
Customer.email.label('customer_email'),
|
|
Customer.full_name.label('customer_full_name'),
|
|
Agent.id.label('agent_id'),
|
|
Agent.name.label('agent_name'),
|
|
Agent.display_name.label('agent_display_name'),
|
|
Agent.ai_replies_enabled.label('agent_ai_replies_enabled'),
|
|
SessionToAgent.status.label('status'),
|
|
SessionToAgent.channel.label('channel'),
|
|
SessionToAgent.group_id.label('group_id'),
|
|
# Who is handling the chat. Without these the list cannot tell an
|
|
# AI-handled conversation from one a human has taken over — the
|
|
# detail view has always known, the overview never did.
|
|
SessionToAgent.user_id.label('user_id'),
|
|
User.full_name.label('user_name'),
|
|
func.max(ChatHistory.created_at).label('updated_at'),
|
|
func.count(ChatHistory.id).label('message_count'),
|
|
SessionToAgent.session_id.label('session_id')
|
|
).join(
|
|
Agent, ChatHistory.agent_id == Agent.id
|
|
).join(
|
|
Customer, ChatHistory.customer_id == Customer.id
|
|
).join(
|
|
SessionToAgent, ChatHistory.session_id == SessionToAgent.session_id
|
|
).outerjoin(
|
|
User, SessionToAgent.user_id == User.id
|
|
)
|
|
|
|
# Filter conditions
|
|
if agent_id:
|
|
query = query.filter(Agent.id == agent_id)
|
|
|
|
# Filter by status if provided
|
|
if status and status != 'all':
|
|
# Handle comma-separated status values
|
|
if ',' in status:
|
|
status_values = [s.strip() for s in status.split(',')]
|
|
query = query.filter(SessionToAgent.status.in_(status_values))
|
|
else:
|
|
query = query.filter(SessionToAgent.status == status)
|
|
|
|
# Filter by organization
|
|
if organization_id:
|
|
query = query.filter(SessionToAgent.organization_id == organization_id)
|
|
|
|
# Filter by user name
|
|
if user_name:
|
|
query = query.filter(User.full_name.ilike(f'%{user_name}%'))
|
|
|
|
# Filter by specific user ID (for agent dropdown)
|
|
if filter_user_id:
|
|
if isinstance(filter_user_id, str):
|
|
filter_user_id = UUID(filter_user_id)
|
|
query = query.filter(SessionToAgent.user_id == filter_user_id)
|
|
|
|
# Filter by customer email
|
|
if customer_email:
|
|
query = query.filter(Customer.email.ilike(f'%{customer_email}%'))
|
|
|
|
# Filter by date range
|
|
if date_from:
|
|
query = query.filter(ChatHistory.created_at >= date_from)
|
|
if date_to:
|
|
query = query.filter(ChatHistory.created_at <= date_to)
|
|
|
|
# Scope to what this user may see: their own sessions, their groups'
|
|
# queue, and — with view_unassigned_chats — the unclaimed AI queue.
|
|
# Callers who may see everything pass none of these.
|
|
visibility = []
|
|
if user_id:
|
|
visibility.append(SessionToAgent.user_id == user_id)
|
|
if user_groups:
|
|
visibility.append(SessionToAgent.group_id.in_(user_groups))
|
|
if include_unassigned:
|
|
# Nobody has taken it yet — the AI is still handling it. group_id
|
|
# is irrelevant: a chat transferred to another group is still
|
|
# claimable, and it is exactly what "unassigned" means.
|
|
visibility.append(SessionToAgent.user_id.is_(None))
|
|
|
|
if visibility:
|
|
query = query.filter(or_(*visibility))
|
|
|
|
# Group by and order
|
|
query = query.group_by(
|
|
Customer.id,
|
|
Customer.email,
|
|
Customer.full_name,
|
|
Agent.id,
|
|
Agent.name,
|
|
Agent.display_name,
|
|
Agent.ai_replies_enabled,
|
|
SessionToAgent.status,
|
|
SessionToAgent.channel,
|
|
SessionToAgent.group_id,
|
|
SessionToAgent.user_id,
|
|
User.full_name,
|
|
SessionToAgent.session_id
|
|
).order_by(
|
|
# Create a custom ordering to prioritize transferred conversations
|
|
# Using direct SQL expression for the CASE statement with uppercase status values
|
|
text("CASE WHEN session_to_agents.status = 'TRANSFERRED' THEN 0 "
|
|
"WHEN session_to_agents.status = 'OPEN' THEN 1 "
|
|
"ELSE 2 END"),
|
|
# Then order by most recent activity
|
|
func.max(ChatHistory.created_at).desc()
|
|
).offset(skip).limit(limit)
|
|
|
|
results = query.all()
|
|
# Previews are fetched for the returned page only. Inlining them as a
|
|
# correlated subquery would evaluate once per session in the org, since the
|
|
# LIMIT applies after grouping.
|
|
last_messages = self.get_last_messages([r.session_id for r in results])
|
|
|
|
return [{
|
|
'customer': {
|
|
'id': r.customer_id,
|
|
'email': CustomerRepository.display_email(r.customer_email),
|
|
'full_name': r.customer_full_name
|
|
},
|
|
'agent': {
|
|
'id': r.agent_id,
|
|
'name': r.agent_name,
|
|
'display_name': r.agent_display_name,
|
|
'ai_replies_enabled': r.agent_ai_replies_enabled
|
|
},
|
|
'last_message': last_messages.get(r.session_id),
|
|
'updated_at': r.updated_at,
|
|
'message_count': r.message_count,
|
|
'status': r.status,
|
|
'channel': r.channel,
|
|
'group_id': str(r.group_id) if r.group_id else None,
|
|
'user_id': r.user_id,
|
|
'user_name': r.user_name,
|
|
'session_id': r.session_id
|
|
} for r in results]
|
|
|
|
async def check_session_access(
|
|
self,
|
|
session_id: str | UUID,
|
|
user_id: Optional[str | UUID],
|
|
user_groups: Optional[List[str]],
|
|
include_unassigned: bool = False
|
|
) -> bool:
|
|
"""Check if user has access to a chat session.
|
|
|
|
Mirrors the visibility filter in get_recent_chats — the two must agree,
|
|
or a session shows in the list and 404s when opened. A None user_id
|
|
means "this caller has no assigned-chat grant"; it must never be
|
|
compared against session.user_id, since both being NULL would silently
|
|
hand over every unclaimed session.
|
|
"""
|
|
if isinstance(session_id, str):
|
|
session_id = UUID(session_id)
|
|
if isinstance(user_id, str):
|
|
user_id = UUID(user_id)
|
|
|
|
session = (
|
|
self.db.query(SessionToAgent)
|
|
.filter(SessionToAgent.session_id == session_id)
|
|
.first()
|
|
)
|
|
|
|
if not session:
|
|
return False
|
|
|
|
if include_unassigned and session.user_id is None:
|
|
return True
|
|
|
|
if user_id is not None and session.user_id == user_id:
|
|
return True
|
|
|
|
return bool(
|
|
session.group_id and user_groups and str(session.group_id) in user_groups
|
|
)
|
|
|
|
async def get_chat_detail(
|
|
self,
|
|
session_id: str | UUID,
|
|
org_id: str | UUID
|
|
) -> Optional[dict]:
|
|
"""Get detailed chat information for a session"""
|
|
if isinstance(session_id, str):
|
|
session_id = UUID(session_id)
|
|
if isinstance(org_id, str):
|
|
org_id = UUID(org_id)
|
|
|
|
result = (
|
|
self.db.query(
|
|
Customer.id.label('customer_id'),
|
|
Customer.email.label('customer_email'),
|
|
Customer.full_name.label('customer_full_name'),
|
|
Customer.meta_data.label('customer_meta_data'),
|
|
Agent.id.label('agent_id'),
|
|
Agent.name.label('agent_name'),
|
|
Agent.display_name.label('agent_display_name'),
|
|
Agent.ai_replies_enabled.label('agent_ai_replies_enabled'),
|
|
SessionToAgent.status.label('status'),
|
|
SessionToAgent.channel.label('channel'),
|
|
SessionToAgent.group_id.label('group_id'),
|
|
SessionToAgent.session_id.label('session_id'),
|
|
SessionToAgent.user_id.label('user_id'),
|
|
User.full_name.label('user_name'),
|
|
func.min(ChatHistory.created_at).label('created_at'),
|
|
func.max(ChatHistory.created_at).label('updated_at')
|
|
)
|
|
.join(Agent, ChatHistory.agent_id == Agent.id)
|
|
.join(Customer, ChatHistory.customer_id == Customer.id)
|
|
.join(SessionToAgent, ChatHistory.session_id == SessionToAgent.session_id)
|
|
.outerjoin(User, SessionToAgent.user_id == User.id)
|
|
.filter(
|
|
ChatHistory.session_id == session_id,
|
|
SessionToAgent.organization_id == org_id
|
|
)
|
|
.group_by(
|
|
# Customer.meta_data is deliberately NOT grouped: Postgres' `json` type
|
|
# has no equality operator, so grouping by it raises "could not identify
|
|
# an equality operator for type json". Customer.id (the PK) is already
|
|
# grouped, so Postgres' functional-dependency rule lets other columns of
|
|
# the same table (email, full_name, meta_data) be selected ungrouped.
|
|
Customer.id,
|
|
Customer.email,
|
|
Customer.full_name,
|
|
Agent.id,
|
|
Agent.name,
|
|
Agent.display_name,
|
|
Agent.ai_replies_enabled,
|
|
SessionToAgent.status,
|
|
SessionToAgent.channel,
|
|
SessionToAgent.group_id,
|
|
SessionToAgent.session_id,
|
|
SessionToAgent.user_id,
|
|
User.full_name
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if not result:
|
|
return None
|
|
|
|
# Get messages for the session
|
|
messages = await self.get_session_history(session_id)
|
|
|
|
# Build messages list with attachments
|
|
messages_list = []
|
|
for msg in messages:
|
|
msg_dict = {
|
|
'id': msg.id,
|
|
'message': msg.message,
|
|
'message_type': msg.message_type,
|
|
'created_at': msg.created_at,
|
|
'attributes': _readable_attributes(msg.attributes),
|
|
# Live delivery receipts are matched to the message by either
|
|
'external_message_id': msg.external_message_id,
|
|
'channel_status': msg.channel_status,
|
|
}
|
|
|
|
# Add attachments with file info if they exist
|
|
if msg.attachments:
|
|
attachments = []
|
|
for attachment in msg.attachments:
|
|
att_dict = {
|
|
'id': attachment.id,
|
|
'filename': attachment.filename,
|
|
'file_url': attachment.file_url,
|
|
'content_type': attachment.content_type,
|
|
'file_size': attachment.file_size
|
|
}
|
|
attachments.append(att_dict)
|
|
msg_dict['attachments'] = attachments
|
|
|
|
messages_list.append(msg_dict)
|
|
|
|
# Convert result to dict
|
|
return {
|
|
'customer': {
|
|
'id': result.customer_id,
|
|
'email': CustomerRepository.display_email(result.customer_email),
|
|
'full_name': result.customer_full_name,
|
|
'meta_data': result.customer_meta_data
|
|
},
|
|
'agent': {
|
|
'id': result.agent_id,
|
|
'name': result.agent_name,
|
|
'display_name': result.agent_display_name,
|
|
'ai_replies_enabled': result.agent_ai_replies_enabled
|
|
},
|
|
'status': result.status,
|
|
'channel': result.channel,
|
|
'channel_account_id': self._channel_account_id(result.channel, result.session_id),
|
|
'group_id': str(result.group_id) if result.group_id else None,
|
|
'session_id': result.session_id,
|
|
'user_id': result.user_id,
|
|
'user_name': result.user_name,
|
|
'created_at': result.created_at,
|
|
'updated_at': result.updated_at,
|
|
'messages': messages_list
|
|
}
|