mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-10 14:15:42 +00:00
60cb85494c
api: accept source_id on conversation and message endpoints
321 lines
11 KiB
Go
321 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.
|
|
}
|
|
|
|
// 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()
|
|
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.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)
|
|
}
|
|
}
|
|
|
|
// Get media for all attachments, 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)
|
|
}
|
|
|
|
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)
|
|
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
|
|
}
|
|
message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, conv.ContactID, cuuid, req.Message, req.To, req.CC, req.BCC, 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)
|
|
}
|