Files
libredesk/cmd/conversation.go
T
Abhinav Raut 6125f5ced0 harden AI agent knowledge base, OTP, and prompt injection defenses
Knowledge base: fold the embedding provider base URL into the snippet
fingerprint so re-pointing the provider triggers a reindex even when the
model name is unchanged. Reject empty knowledge base content. Cap
concurrent background snippet embeds at 4 and tie embedding work to the
app lifecycle context. Reindex when the embedding base URL changes, not
just the model or dimensions.

Prompt injection: neutralize << >> block delimiters in snippets,
transcripts, subjects, and contact fields so untrusted content can't
forge a boundary the model relies on.

OTP: set the verified flag inside the Lua match script so verification
is atomic. Only count codes that were actually emailed toward the resend
cap, and check the cap before sending instead of incrementing up front.

Tools: fetch only the enabled tools among the allowed IDs, and route all
registrations through one helper so custom tools can't shadow built-ins.

Agent queue: hand a dropped response job off to a human instead of
leaving the conversation assigned to the assistant with no reply.

Also let GetAllConversationMessages return every message when limit is
non-positive, populate admin forms without triggering validation, and
default the copilot name to Juno.
2026-07-22 14:56:49 +05:30

948 lines
31 KiB
Go

package main
import (
"encoding/json"
"fmt"
"slices"
"strconv"
"strings"
"time"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
authzModels "github.com/abhinavxd/libredesk/internal/authz/models"
"github.com/abhinavxd/libredesk/internal/automation/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/stringutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
vmodels "github.com/abhinavxd/libredesk/internal/view/models"
wmodels "github.com/abhinavxd/libredesk/internal/webhook/models"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
type assigneeChangeReq struct {
AssigneeID int `json:"assignee_id"`
}
type teamAssigneeChangeReq struct {
AssigneeID int `json:"assignee_id"`
}
type priorityUpdateReq struct {
Priority string `json:"priority"`
}
type statusUpdateReq struct {
Status string `json:"status"`
SnoozedUntil string `json:"snoozed_until,omitempty"`
}
type tagsUpdateReq struct {
Tags []string `json:"tags"`
Action string `json:"action,omitempty"`
}
type createConversationRequest struct {
InboxID int `json:"inbox_id"`
AssignedAgentID int `json:"agent_id"`
AssignedTeamID int `json:"team_id"`
Email string `json:"contact_email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
ExternalUserID string `json:"external_user_id"`
Subject string `json:"subject"`
Content string `json:"content"`
Attachments []int `json:"attachments"`
Initiator string `json:"initiator"` // "contact" | "agent"
CustomAttributes map[string]any `json:"custom_attributes"`
}
// handleGetAllConversations retrieves all conversations.
func handleGetAllConversations(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(amodels.User)
order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
total = 0
)
page, pageSize := getPagination(r)
conversations, err := app.conversation.GetAllConversationsList(user.ID, order, orderBy, filters, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
if len(conversations) > 0 {
total = conversations[0].Total
}
return r.SendEnvelope(envelope.PageResults{
Results: conversations,
Total: total,
PerPage: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}
// handleGetAssignedConversations retrieves conversations assigned to the current user.
func handleGetAssignedConversations(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(amodels.User)
order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
total = 0
)
page, pageSize := getPagination(r)
conversations, err := app.conversation.GetAssignedConversationsList(user.ID, user.ID, order, orderBy, filters, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
if len(conversations) > 0 {
total = conversations[0].Total
}
return r.SendEnvelope(envelope.PageResults{
Results: conversations,
Total: total,
PerPage: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}
// handleGetUnassignedConversations retrieves unassigned conversations.
func handleGetUnassignedConversations(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(amodels.User)
order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
total = 0
)
page, pageSize := getPagination(r)
conversations, err := app.conversation.GetUnassignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
if len(conversations) > 0 {
total = conversations[0].Total
}
return r.SendEnvelope(envelope.PageResults{
Results: conversations,
Total: total,
PerPage: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}
// handleGetMentionedConversations retrieves conversations where the current user is mentioned.
func handleGetMentionedConversations(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(amodels.User)
order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
total = 0
)
page, pageSize := getPagination(r)
conversations, err := app.conversation.GetMentionedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
if len(conversations) > 0 {
total = conversations[0].Total
}
return r.SendEnvelope(envelope.PageResults{
Results: conversations,
Total: total,
PerPage: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}
// handleGetViewConversations retrieves conversations for a view.
func handleGetViewConversations(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
viewID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
total = 0
)
page, pageSize := getPagination(r)
if viewID < 1 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
// Check if user has access to the view.
view, err := app.view.Get(viewID)
if err != nil {
return sendErrorEnvelope(r, err)
}
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
hasAccess := false
switch view.Visibility {
case vmodels.VisibilityUser:
hasAccess = view.UserID != nil && *view.UserID == auser.ID
case vmodels.VisibilityAll:
hasAccess = true
case vmodels.VisibilityTeam:
if view.TeamID != nil {
hasAccess = slices.Contains(user.Teams.IDs(), *view.TeamID)
}
}
if !hasAccess {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("conversation.viewPermissionDenied"), nil, envelope.PermissionError)
}
// Prepare lists user has access to based on user permissions, internally this prepares the SQL query.
lists := []string{}
hasTeamAll := slices.Contains(user.Permissions, authzModels.PermConversationsReadTeamAll)
for _, perm := range user.Permissions {
if perm == authzModels.PermConversationsReadAll {
// No further lists required as user has access to all conversations.
lists = []string{cmodels.AllConversations}
break
}
if perm == authzModels.PermConversationsReadUnassigned {
lists = append(lists, cmodels.UnassignedConversations)
}
if perm == authzModels.PermConversationsReadAssigned {
lists = append(lists, cmodels.AssignedConversations)
}
// Skip TeamUnassignedConversations if user has TeamAllConversations (superset).
if perm == authzModels.PermConversationsReadTeamInbox && !hasTeamAll {
lists = append(lists, cmodels.TeamUnassignedConversations)
}
if perm == authzModels.PermConversationsReadTeamAll {
lists = append(lists, cmodels.TeamAllConversations)
}
}
// No lists found, user doesn't have access to any conversations.
if len(lists) == 0 {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("status.deniedPermission"), nil, envelope.PermissionError)
}
conversations, err := app.conversation.GetViewConversationsList(user.ID, user.ID, user.Teams.IDs(), lists, order, orderBy, string(view.Filters), page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
if len(conversations) > 0 {
total = conversations[0].Total
}
return r.SendEnvelope(envelope.PageResults{
Results: conversations,
Total: total,
PerPage: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}
// handleGetTeamUnassignedConversations returns conversations assigned to a team but not to any user.
func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
teamIDStr = r.RequestCtx.UserValue("id").(string)
order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
total = 0
)
page, pageSize := getPagination(r)
teamID, _ := strconv.Atoi(teamIDStr)
if teamID < 1 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
// Check if user belongs to the team.
exists, err := app.team.UserBelongsToTeam(teamID, auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !exists {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, app.i18n.T("conversation.notMemberOfTeam"), nil))
}
conversations, err := app.conversation.GetTeamUnassignedConversationsList(auser.ID, teamID, order, orderBy, filters, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
if len(conversations) > 0 {
total = conversations[0].Total
}
return r.SendEnvelope(envelope.PageResults{
Results: conversations,
Total: total,
PerPage: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}
// handleGetConversation retrieves a single conversation by it's UUID.
func handleGetConversation(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
conv, err := enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
prev, _ := app.conversation.GetContactPreviousConversations(conv.ContactID, 10)
conv.PreviousConversations = filterCurrentPreviousConv(prev, conv.UUID)
return r.SendEnvelope(conv)
}
// handleDownloadConversationTranscript sends the conversation transcript as a text file download.
func handleDownloadConversationTranscript(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
conversation, err := enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
private := false
messages, err := app.conversation.GetAllConversationMessages(uuid, &private, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing}, 0)
if err != nil {
return sendErrorEnvelope(r, err)
}
transcript := app.conversation.BuildTranscript(*conversation, messages, time.Now())
safeRef := stringutil.SanitizeFilename(conversation.ReferenceNumber)
filename := fmt.Sprintf("transcript-%s.txt", safeRef)
r.RequestCtx.Response.Header.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff")
r.RequestCtx.SetContentType("text/plain; charset=utf-8")
r.RequestCtx.SetBody(transcript)
return nil
}
// handleGetContactPageVisits returns the recent page visits for the contact of a conversation.
func handleGetContactPageVisits(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
conv, err := enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
pages := getPageVisitsFromRedis(app, conv.ContactID)
return r.SendEnvelope(pages)
}
// handleUpdateConversationAssigneeLastSeen updates the current user's last seen timestamp for a conversation.
func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.conversation.UpdateUserLastSeen(uuid, auser.ID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleMarkConversationAsUnread marks a conversation as unread for the current user.
func handleMarkConversationAsUnread(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.conversation.MarkAsUnread(uuid, auser.ID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleGetConversationParticipants retrieves participants of a conversation.
func handleGetConversationParticipants(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
p, err := app.conversation.GetConversationParticipants(uuid)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(p)
}
// handleUpdateUserAssignee updates the user assigned to a conversation.
func handleUpdateUserAssignee(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = assigneeChangeReq{}
)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding assignee change request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
conversation, err := enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Already assigned?
if conversation.AssignedUserID.Int == req.AssigneeID {
return r.SendEnvelope(true)
}
if err := app.conversation.UpdateConversationUserAssignee(uuid, req.AssigneeID, user); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateTeamAssignee updates the team assigned to a conversation.
func handleUpdateTeamAssignee(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = teamAssigneeChangeReq{}
)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding team assignee change request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
assigneeID := req.AssigneeID
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
_, err = app.team.Get(assigneeID)
if err != nil {
return sendErrorEnvelope(r, err)
}
conversation, err := enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Already assigned?
if conversation.AssignedTeamID.Int == assigneeID {
return r.SendEnvelope(true)
}
if err := app.conversation.UpdateConversationTeamAssignee(uuid, assigneeID, user); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateConversationPriority updates the priority of a conversation.
func handleUpdateConversationPriority(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = priorityUpdateReq{}
)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding priority update request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
priority := req.Priority
if priority == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`priority`"), nil, envelope.InputError)
}
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.conversation.UpdateConversationPriority(uuid, 0 /**priority_id**/, priority, user); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateConversationStatus updates the status of a conversation.
func handleUpdateConversationStatus(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = statusUpdateReq{}
)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding status update request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
status := req.Status
snoozedUntil := req.SnoozedUntil
// Validate inputs
if status == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`status`"), nil, envelope.InputError)
}
if snoozedUntil == "" && status == cmodels.StatusSnoozed {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`snoozed_until`"), nil, envelope.InputError)
}
if status == cmodels.StatusSnoozed {
_, err := time.ParseDuration(snoozedUntil)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
}
}
// Enforce conversation access.
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
conversation, err := enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Update conversation status.
if err := app.conversation.UpdateConversationStatus(uuid, 0 /**status_id**/, status, snoozedUntil, user); err != nil {
return sendErrorEnvelope(r, err)
}
markAssignmentNotificationRead(app, conversation, user)
return r.SendEnvelope(true)
}
// handleUpdateConversationtags updates conversation tags.
func handleUpdateConversationtags(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
req = tagsUpdateReq{}
)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding tags update request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
tagNames := req.Tags
// Default to set tags if action is not provided (backwards compatibility).
action := models.ActionSetTags
switch req.Action {
case models.ActionAddTags, models.ActionRemoveTags, models.ActionSetTags:
action = req.Action
case "":
default:
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.conversation.SetConversationTags(uuid, action, tagNames, user); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateConversationCustomAttributes updates custom attributes of a conversation.
func handleUpdateConversationCustomAttributes(r *fastglue.Request) error {
var (
app = r.Context.(*App)
attributes = map[string]any{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
if err := r.Decode(&attributes, ""); err != nil {
app.lo.Error("error unmarshalling custom attributes JSON", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
// Enforce conversation access.
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Update custom attributes.
if err := app.conversation.UpdateConversationCustomAttributes(uuid, attributes); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateContactCustomAttributes updates custom attributes of a contact.
func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
var (
app = r.Context.(*App)
attributes = map[string]any{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
if err := r.Decode(&attributes, ""); err != nil {
app.lo.Error("error unmarshalling custom attributes JSON", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
// Enforce conversation access.
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
conversation, err := enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.user.SaveCustomAttributes(conversation.ContactID, attributes, false); err != nil {
return sendErrorEnvelope(r, err)
}
// Broadcast update.
app.conversation.BroadcastContactUpdate(conversation.ContactID, map[string]any{"custom_attributes": attributes})
return r.SendEnvelope(true)
}
// enforceConversationAccess fetches the conversation and checks if the user has access to it.
func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmodels.Conversation, error) {
conversation, err := app.conversation.GetConversation(0, uuid, "")
if err != nil {
return nil, err
}
allowed, err := app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return nil, err
}
if !allowed {
return nil, envelope.NewError(envelope.PermissionError, "Permission denied", nil)
}
return &conversation, nil
}
// handleRemoveUserAssignee removes the user assigned to a conversation.
func handleRemoveUserAssignee(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.conversation.RemoveConversationAssignee(uuid, "user", user); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleRemoveTeamAssignee removes the team assigned to a conversation.
func handleRemoveTeamAssignee(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.conversation.RemoveConversationAssignee(uuid, "team", user); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// filterCurrentPreviousConv removes the current conversation from the list of previous conversations.
func filterCurrentPreviousConv(convs []cmodels.PreviousConversation, uuid string) []cmodels.PreviousConversation {
for i, c := range convs {
if c.UUID == uuid {
return append(convs[:i], convs[i+1:]...)
}
}
return []cmodels.PreviousConversation{}
}
// handleCreateConversation creates a new conversation and sends a message to it.
func handleCreateConversation(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = createConversationRequest{}
)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding create conversation request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
// Validate the request
if err := validateCreateConversationRequest(req, app); err != nil {
return sendErrorEnvelope(r, err)
}
email := req.Email
to := []string{email}
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
contact := umodels.User{
Email: null.StringFrom(email),
FirstName: req.FirstName,
LastName: req.LastName,
ExternalUserID: null.NewString(req.ExternalUserID, req.ExternalUserID != ""),
CustomAttributes: json.RawMessage(`{}`),
}
// Reuse an existing contact as-is; this endpoint is gated only on conversations:write and must never rename a contact.
existing, err := app.user.GetContactByEmail(email)
if err != nil {
if envErr, ok := err.(envelope.Error); !ok || envErr.ErrorType != envelope.NotFoundError {
return sendErrorEnvelope(r, err)
}
if err := app.user.CreateContact(&contact); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil))
}
} else {
contact.ID = existing.ID
}
// Create conversation first.
conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID,
req.InboxID,
"", /** last_message **/
time.Now(), /** last_message_at **/
req.Subject,
true, /** append reference number to subject? **/
nil,
req.CustomAttributes,
0, 0,
)
if err != nil {
app.lo.Error("error creating conversation", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil))
}
// Get media for the attachment ids, skip any already associated with a model.
media, err := getUnassociatedMedia(app, req.Attachments)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
}
// Assign team first, it clears any assigned agent.
if req.AssignedTeamID > 0 {
app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user)
}
if req.AssignedAgentID > 0 {
app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user)
}
// Send initial message based on the initiator of conversation.
switch req.Initiator {
case umodels.UserTypeAgent:
// Queue reply.
if _, err := app.conversation.QueueReply(media, req.InboxID, auser.ID /**sender_id**/, contact.ID, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if msg queue fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.errorSendingMessage"), nil))
}
// Trigger webhook for agent-initiated conversation, for contact intitiated the incoming message hooks handle it.
if c, err := app.conversation.GetConversation(0, conversationUUID, ""); err == nil {
app.webhook.TriggerEvent(wmodels.EventConversationCreated, c)
}
case umodels.UserTypeContact:
// Create contact message.
if _, err := app.conversation.CreateContactMessage(media, contact.ID, conversationUUID, req.Content, cmodels.ContentTypeHTML, true); err != nil {
// Delete the conversation if message creation fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.errorSendingMessage"), nil))
}
default:
// Guard anyway.
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
conversation, _ := app.conversation.GetConversation(conversationID, "", "")
return r.SendEnvelope(conversation)
}
// validateCreateConversationRequest validates the create conversation request fields.
func validateCreateConversationRequest(req createConversationRequest, app *App) error {
if req.InboxID <= 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil)
}
if req.Content == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil)
}
if req.Email == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil)
}
if req.FirstName == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil)
}
if !stringutil.ValidEmail(req.Email) {
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidEmail"), nil)
}
if req.Initiator != umodels.UserTypeContact && req.Initiator != umodels.UserTypeAgent {
return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
}
// Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(req.InboxID)
if err != nil {
return err
}
if !inbox.Enabled {
return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.disabled"), nil)
}
if inbox.Channel != "email" {
return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
}
// Validate custom attribute keys. Skip unknown keys.
if len(req.CustomAttributes) > 0 {
attrs, err := app.customAttribute.GetAll("conversation")
if err != nil {
return err
}
validKeys := make(map[string]struct{}, len(attrs))
for _, a := range attrs {
validKeys[a.Key] = struct{}{}
}
for key := range req.CustomAttributes {
if _, ok := validKeys[key]; !ok {
delete(req.CustomAttributes, key)
}
}
}
return nil
}