fix(ai): compute email recipients for AI and automated replies

- Add SendAutoReply method that automatically determines to/cc/bcc based on conversation history. Fixes AI assistant replies failing for email conversations while maintaining livechat compatibility.
This commit is contained in:
Abhinav Raut
2025-08-24 15:47:03 +05:30
parent af1373272e
commit 6f62a77783
5 changed files with 59 additions and 33 deletions

View File

@@ -14,18 +14,24 @@
<FormField v-slot="{ componentField }" name="help_center_id">
<FormItem>
<FormLabel>{{ $t('admin.inbox.helpCenter') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.helpCenter') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue :placeholder="t('admin.inbox.helpCenter.placeholder')" />
<SelectValue
:placeholder="
t('globals.messages.select', {
name: $t('globals.terms.helpCenter').toLowerCase()
})
"
/>
</SelectTrigger>
<SelectContent>
<SelectItem :value="0">{{ $t('globals.terms.none') }}</SelectItem>
<SelectItem
v-for="helpCenter in helpCenters"
:key="helpCenter.id"
:value="helpCenter.id.toString()"
:value="helpCenter.id"
>
{{ helpCenter.name }}
</SelectItem>

View File

@@ -39,7 +39,7 @@ var (
)
type ConversationStore interface {
SendReply(media []mmodels.Media, inboxID, senderID, contactID int, conversationUUID, content string, to, cc, bcc []string, metaMap map[string]any) (cmodels.Message, error)
SendAutoReply(media []mmodels.Media, inboxID, senderID, contactID int, conversationUUID, content string, metaMap map[string]any) (cmodels.Message, error)
RemoveConversationAssignee(uuid, typ string, actor umodels.User) error
UpdateConversationTeamAssignee(uuid string, teamID int, actor umodels.User) error
UpdateConversationStatus(uuid string, statusID int, status, snoozeDur string, actor umodels.User) error

View File

@@ -307,16 +307,13 @@ func (s *ConversationCompletionsService) processCompletionRequest(req models.Con
metaMap["ai_reasoning"] = reasoning
}
if _, err = s.conversationStore.SendReply(
if _, err = s.conversationStore.SendAutoReply(
nil, // No media attachments for AI responses
req.InboxID,
req.AIAssistant.ID,
req.ContactID,
req.ConversationUUID,
finalResponse,
nil, // to
nil, // cc
nil, // bcc
metaMap,
); err != nil {
s.lo.Error("error sending AI response", "conversation_uuid", req.ConversationUUID, "error", err)

View File

@@ -1022,25 +1022,17 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
return fmt.Errorf("sending private note: %w", err)
}
case amodels.ActionReply:
// Make recipient list.
to, cc, bcc, err := m.makeRecipients(conv.ID, conv.Contact.Email.String, conv.InboxMail)
if err != nil {
return fmt.Errorf("making recipients for reply action: %w", err)
}
_, err = m.SendReply(
_, err := m.SendAutoReply(
[]mmodels.Media{},
conv.InboxID,
user.ID,
conv.ContactID,
conv.UUID,
action.Value[0],
to,
cc,
bcc,
map[string]any{}, /**meta**/
)
if err != nil {
return fmt.Errorf("sending reply: %w", err)
return fmt.Errorf("sending automated reply: %w", err)
}
case amodels.ActionSetSLA:
slaID, err := strconv.Atoi(action.Value[0])
@@ -1096,14 +1088,8 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
"is_csat": true,
}
// Make recipient list.
to, cc, bcc, err := m.makeRecipients(conversation.ID, conversation.Contact.Email.String, conversation.InboxMail)
if err != nil {
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil)
}
// Send CSAT reply.
_, err = m.SendReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.ContactID, conversation.UUID, message, to, cc, bcc, meta)
// Send CSAT reply with auto-computed recipients.
_, err = m.SendAutoReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.ContactID, conversation.UUID, message, meta)
if err != nil {
m.lo.Error("error sending CSAT reply", "conversation_uuid", conversation.UUID, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil)

View File

@@ -388,6 +388,26 @@ func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversat
return message, nil
}
// SendAutoReply sends a reply with automatically computed recipients based on the conversation's last message.
// For incoming messages: replies to the sender (from), CC'ing other participants.
// For outgoing messages: continues the existing recipient list.
// Used for AI responses, automation rules, and CSAT replies where manual recipient selection isn't needed.
func (m *Manager) SendAutoReply(media []mmodels.Media, inboxID, senderID, contactID int, conversationUUID, content string, metaMap map[string]any) (models.Message, error) {
conv, err := m.GetConversation(0, conversationUUID)
if err != nil {
return models.Message{}, fmt.Errorf("fetching conversation for auto reply: %w", err)
}
// Compute recipients
to, cc, bcc, err := m.makeRecipients(conv.ID, conv.Contact.Email.String, conv.InboxMail)
if err != nil {
return models.Message{}, fmt.Errorf("computing recipients for auto reply: %w", err)
}
// Send reply with computed recipients
return m.SendReply(media, inboxID, senderID, contactID, conversationUUID, content, to, cc, bcc, metaMap)
}
// SendReply inserts a reply message for a conversation.
func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID, contactID int, conversationUUID, content string, to, cc, bcc []string, metaMap map[string]any) (models.Message, error) {
inboxRecord, err := m.inboxStore.GetDBRecord(inboxID)
@@ -1045,6 +1065,9 @@ func (m *Manager) ProcessIncomingMessageHooks(conversationUUID string, isNewConv
if err != nil {
m.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err)
} else {
// Enqueue conversation for AI completion if assigned to an AI assistant.
m.enqueueMessageForAICompletion(conversation)
// Trigger automations on incoming message event.
m.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationMessageIncoming)
@@ -1062,14 +1085,13 @@ func (m *Manager) ProcessIncomingMessageHooks(conversationUUID string, isNewConv
}
}
// Enqueue conversation for AI completion if assigned to an AI assistant.
m.enqueueMessageForAICompletion(conversation)
return nil
}
// enqueueMessageForAICompletion enqueues message for AI completion if the conversation is assigned to an AI assistant and if the inbox has help center attached.
func (m *Manager) enqueueMessageForAICompletion(conversation models.Conversation) {
m.lo.Debug("checking conversation for AI completion", "conversation_id", conversation.ID)
if m.aiStore == nil {
m.lo.Warn("AI store not configured, skipping AI completion request")
return
@@ -1087,6 +1109,7 @@ func (m *Manager) enqueueMessageForAICompletion(conversation models.Conversation
// Only process incoming messages from contacts.
if latestMsg.Type != models.MessageIncoming || latestMsg.SenderType != models.SenderTypeContact {
m.lo.Debug("latest message is not from a contact, skipping AI completion", "conversation_id", conversation.ID, "type", latestMsg.Type, "sender_type", latestMsg.SenderType)
return
}
@@ -1097,8 +1120,15 @@ func (m *Manager) enqueueMessageForAICompletion(conversation models.Conversation
return
}
// Check if conversation is assigned to a user and inbox has help center attached.
if conversation.AssignedUserID.Int <= 0 || !inbox.HelpCenterID.Valid {
// Make sure the conversation has an assigned user.
if !conversation.AssignedUserID.Valid {
m.lo.Debug("conversation is not assigned to a user, skipping AI completion", "conversation_id", conversation.ID)
return
}
// Make sure there's an helpcenter linked to the inbox as AI completions use help center articles for context.
if !inbox.HelpCenterID.Valid {
m.lo.Debug("inbox is not linked to a help center, skipping AI completion", "inbox_id", conversation.InboxID)
return
}
@@ -1108,11 +1138,18 @@ func (m *Manager) enqueueMessageForAICompletion(conversation models.Conversation
m.lo.Error("error fetching assignee user for AI completion", "assignee_user_id", conversation.AssignedUserID.Int, "error", err)
return
}
if !assigneUser.IsAiAssistant() || !assigneUser.Enabled {
if !assigneUser.IsAiAssistant() {
m.lo.Debug("conversation is not assigned to an AI assistant, skipping AI completion", "conversation_id", conversation.ID)
return
}
messages, _, err := m.GetConversationMessages(conversation.UUID, []string{models.MessageIncoming, models.MessageOutgoing}, nil, 1, 50)
if !assigneUser.Enabled {
m.lo.Debug("AI assistant is not enabled, skipping AI completion", "conversation_id", conversation.ID)
return
}
messages, _, err := m.GetConversationMessages(conversation.UUID, []string{models.MessageIncoming, models.MessageOutgoing}, nil, 1, 20)
if err != nil {
m.lo.Error("error fetching conversation message history for AI completion", "conversation_uuid", conversation.UUID, "error", err)
return