Files
libredesk/cmd/messages.go
Abhinav Raut 170fcd026d add help article citations to AI replies
Link cited public help articles in chat replies and emails. Batch reference lookups and keep source metadata out of customer replies.
2026-10-10 20:57:12 +05:30

334 lines
11 KiB
Go

package main
import (
"slices"
"strings"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
authzModels "github.com/abhinavxd/libredesk/internal/authz/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
type messageReq struct {
Attachments []int `json:"attachments"`
Message string `json:"message"`
Private bool `json:"private"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc"`
SenderType string `json:"sender_type"`
Mentions []cmodels.MentionInput `json:"mentions"`
EchoID string `json:"echo_id"`
SourceID string `json:"source_id"` // RFC 5322 Message-ID of the inbound message; stored on the created contact message so replies thread on it. Contact sender only.
SendFrom string `json:"send_from"`
// WhatsApp-only. Set TemplateID to send an approved template. Omit for free-form.
WhatsAppTemplateID int `json:"whatsapp_template_id,omitempty"`
WhatsAppTemplateParams map[string]string `json:"whatsapp_template_params,omitempty"`
}
// handleGetMessages returns messages for a conversation.
func handleGetMessages(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
total = 0
private *bool
)
page, pageSize := getPagination(r)
// Parse optional private filter (null = no filter)
if r.RequestCtx.QueryArgs().Has("private") {
p := r.RequestCtx.QueryArgs().GetBool("private")
private = &p
}
// Parse repeated type params: ?type=incoming&type=outgoing
var msgTypes []string
for _, v := range r.RequestCtx.QueryArgs().PeekMulti("type") {
msgTypes = append(msgTypes, string(v))
}
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check permission
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
messages, pageSize, err := app.conversation.GetConversationMessages(uuid, page, pageSize, private, msgTypes)
if err != nil {
return sendErrorEnvelope(r, err)
}
rootURL, _ := app.setting.GetAppRootURL()
app.conversation.RenderMessagesArticleReferences(messages)
for i := range messages {
total = messages[i].Total
app.conversation.SignAttachmentURLs(messages[i].Attachments)
resolveQuotedCIDs(app, &messages[i])
resolveAttachmentCIDs(&messages[i], rootURL)
}
// Process CSAT status for all messages (will only affect CSAT messages)
app.conversation.ProcessCSATStatus(messages)
// Strip CSAT UUID from agent sessions to prevent self-rating.
if r.RequestCtx.UserValue("auth_method") != authMethodAPIKey {
for i := range messages {
if messages[i].HasCSAT() {
messages[i].StripCSATUUID()
}
}
}
return r.SendEnvelope(envelope.PageResults{
Total: total,
Results: messages,
Page: page,
PerPage: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
})
}
// handleGetMessage fetches a single from DB using the uuid.
func handleGetMessage(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
cuuid = r.RequestCtx.UserValue("cuuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check permission
_, err = enforceConversationAccess(app, cuuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
message, err := app.conversation.GetMessage(uuid)
if err != nil {
return sendErrorEnvelope(r, err)
}
if message.ConversationUUID != cuuid {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
}
// Process CSAT status for the message (will only affect CSAT messages)
messages := []cmodels.Message{message}
app.conversation.ProcessCSATStatus(messages)
message = messages[0]
// Strip CSAT UUID from agent sessions to prevent self-rating.
if r.RequestCtx.UserValue("auth_method") != authMethodAPIKey && message.HasCSAT() {
message.StripCSATUUID()
}
rootURL, _ := app.setting.GetAppRootURL()
app.conversation.RenderArticleReferences(&message)
app.conversation.SignAttachmentURLs(message.Attachments)
resolveQuotedCIDs(app, &message)
resolveAttachmentCIDs(&message, rootURL)
return r.SendEnvelope(message)
}
// handleRetryMessage changes message status to `pending`, so it's enqueued for sending.
func handleRetryMessage(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
cuuid = r.RequestCtx.UserValue("cuuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check permission
_, err = enforceConversationAccess(app, cuuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Only outgoing agent messages that have failed can be retried.
msg, err := app.conversation.GetMessage(uuid)
if err != nil {
return sendErrorEnvelope(r, err)
}
if msg.SenderType != cmodels.SenderTypeAgent || msg.Status != cmodels.MessageStatusFailed || msg.SenderID != user.ID || msg.ConversationUUID != cuuid {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
}
if err = app.conversation.MarkMessageAsPending(uuid); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleSendMessage sends a message in a conversation.
func handleSendMessage(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
cuuid = r.RequestCtx.UserValue("cuuid").(string)
req = messageReq{}
)
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check access to conversation.
conv, err := enforceConversationAccess(app, cuuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error unmarshalling message request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
if !canCreateConversationMessage(user, req) {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("status.deniedPermission"), nil, envelope.PermissionError)
}
// Make sure the inbox is enabled.
inbox, err := app.inbox.GetDBRecord(conv.InboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("status.disabledInbox"), nil, envelope.InputError)
}
if req.SenderType != umodels.UserTypeAgent && req.SenderType != umodels.UserTypeContact {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
// Contacts cannot send private messages
if req.SenderType == umodels.UserTypeContact && req.Private {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
}
// Check if user has permission to send messages as contact
if req.SenderType == umodels.UserTypeContact {
parts := strings.Split(authzModels.PermMessagesWriteAsContact, ":")
if len(parts) != 2 {
app.lo.Error("error parsing permission string", "permission", authzModels.PermMessagesWriteAsContact)
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.somethingWentWrong"), nil))
}
ok, err := app.authz.Enforce(user, parts[0], parts[1])
if err != nil {
app.lo.Error("error checking permission", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.somethingWentWrong"), nil))
}
if !ok {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("status.deniedPermission"), nil, envelope.PermissionError)
}
}
media, err := getUnassociatedMedia(app, req.Attachments, auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
rootURL, _ := app.setting.GetAppRootURL()
// Create contact message.
if req.SenderType == umodels.UserTypeContact {
message, err := app.conversation.CreateContactMessage(media, int(conv.ContactID), cuuid, req.Message, cmodels.ContentTypeHTML, false, req.SourceID, auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
resolveQuotedCIDs(app, &message)
resolveAttachmentCIDs(&message, rootURL)
return r.SendEnvelope(message)
}
// Send private note.
if req.Private {
message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message, req.Mentions)
if err != nil {
return sendErrorEnvelope(r, err)
}
resolveAttachmentCIDs(&message, rootURL)
return r.SendEnvelope(message)
}
// Queue outgoing reply.
meta := map[string]any{}
if req.EchoID != "" {
meta["echo_id"] = req.EchoID
}
if req.WhatsAppTemplateID > 0 {
meta["whatsapp_template_id"] = req.WhatsAppTemplateID
}
if len(req.WhatsAppTemplateParams) > 0 {
meta["whatsapp_template_params"] = req.WhatsAppTemplateParams
}
message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, conv.ContactID, cuuid, req.Message, req.To, req.CC, req.BCC, req.SendFrom, meta)
if err != nil {
return sendErrorEnvelope(r, err)
}
markAssignmentNotificationRead(app, conv, user)
resolveQuotedCIDs(app, &message)
resolveAttachmentCIDs(&message, rootURL)
return r.SendEnvelope(message)
}
// resolveAttachmentCIDs replaces inline image cid: references in email message content
// with actual attachment URLs and resolves relative /uploads/ paths to absolute URLs.
func resolveAttachmentCIDs(msg *cmodels.Message, rootURL string) {
for _, att := range msg.Attachments {
if att.ContentID != "" && att.URL != "" {
msg.Content = strings.ReplaceAll(msg.Content, "cid:"+att.ContentID, att.URL)
}
}
if rootURL != "" {
msg.Content = strings.ReplaceAll(msg.Content, `src="/uploads/`, `src="`+rootURL+`/uploads/`)
msg.Content = strings.ReplaceAll(msg.Content, `src='/uploads/`, `src='`+rootURL+`/uploads/`)
}
}
// resolveQuotedCIDs replaces cid: refs to media on other messages with signed URLs.
func resolveQuotedCIDs(app *App, msg *cmodels.Message) {
refs, err := app.conversation.GetInlineMediaRefs(msg)
if err != nil {
app.lo.Error("error fetching inline media refs", "conversation_uuid", msg.ConversationUUID, "error", err)
return
}
for _, ref := range refs {
url := app.media.GetURL(ref.UUID, ref.ContentType, ref.Filename)
msg.Content = strings.ReplaceAll(msg.Content, "cid:"+ref.ContentID, url)
}
}
// canCreateConversationMessage returns whether the user may create a message of the requested visibility.
func canCreateConversationMessage(user umodels.User, req messageReq) bool {
requiredPermission := authzModels.PermMessagesWrite
if req.Private {
requiredPermission = authzModels.PermMessagesWritePrivate
}
return slices.Contains(user.Permissions, requiredPermission)
}