diff --git a/cmd/chat.go b/cmd/chat.go index 76f9b18b..d0262663 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -1,12 +1,8 @@ package main import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" - "net/http" "path/filepath" "slices" "strconv" @@ -14,26 +10,18 @@ import ( "time" "github.com/abhinavxd/libredesk/internal/attachment" - "github.com/abhinavxd/libredesk/internal/conversation/models" + bhmodels "github.com/abhinavxd/libredesk/internal/business_hours/models" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/envelope" - "github.com/abhinavxd/libredesk/internal/image" "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" "github.com/abhinavxd/libredesk/internal/stringutil" umodels "github.com/abhinavxd/libredesk/internal/user/models" "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" "github.com/valyala/fasthttp" "github.com/volatiletech/null/v9" "github.com/zerodha/fastglue" ) -const ( - // TODO: Can have a global route that serves media files with a signature and expiry. - // Or use the same existing `/uploads` - chatWidgetMediaURL = "/api/v1/widget/media/%s?signature=%s&expires=%d" -) - type onlyJWT struct { JWT string `json:"jwt"` } @@ -57,8 +45,8 @@ type chatInitReq struct { } type conversationResp struct { - Conversation models.ChatConversation `json:"conversation"` - Messages []models.ChatMessage `json:"messages"` + Conversation cmodels.ChatConversation `json:"conversation"` + Messages []cmodels.ChatMessage `json:"messages"` } type chatMessageReq struct { @@ -66,6 +54,59 @@ type chatMessageReq struct { onlyJWT } +type chatSettingsResponse struct { + livechat.Config + BusinessHours []bhmodels.BusinessHours `json:"business_hours,omitempty"` + DefaultBusinessHoursID int `json:"default_business_hours_id,omitempty"` +} + +// conversationResponseWithBusinessHours includes business hours info for the widget +type conversationResponseWithBusinessHours struct { + conversationResp + BusinessHoursID *int `json:"business_hours_id,omitempty"` + WorkingHoursUTCOffset *int `json:"working_hours_utc_offset,omitempty"` +} + +// TODO: live chat widget can have a different language setting than the main app, handle this. +// +// handleGetChatLauncherSettings returns the live chat launcher settings for the widget +func handleGetChatLauncherSettings(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + inboxID = r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id") + ) + + if inboxID <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + // Get inbox configuration + inbox, err := app.inbox.GetDBRecord(inboxID) + if err != nil { + app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.NotFoundError) + } + + if inbox.Channel != livechat.ChannelLiveChat { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + if !inbox.Enabled { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + var config livechat.Config + if err := json.Unmarshal(inbox.Config, &config); err != nil { + app.lo.Error("error parsing live chat config", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) + } + + return r.SendEnvelope(map[string]any{ + "launcher": config.Launcher, + "colors": config.Colors, + }) +} + // handleGetChatSettings returns the live chat settings for the widget func handleGetChatSettings(r *fastglue.Request) error { var ( @@ -98,7 +139,35 @@ func handleGetChatSettings(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) } - return r.SendEnvelope(config) + // Get business hours data if office hours feature is enabled. + response := chatSettingsResponse{ + Config: config, + } + + if config.ShowOfficeHoursInChat { + // Get all business hours. + businessHours, err := app.businessHours.GetAll() + if err != nil { + app.lo.Error("error fetching business hours", "error", err) + } else { + response.BusinessHours = businessHours + } + + // Get default business hours ID from general settings which is the default / fallback. + out, err := app.setting.GetByPrefix("app") + if err != nil { + app.lo.Error("error fetching general settings", "error", err) + } else { + var settings map[string]any + if err := json.Unmarshal(out, &settings); err == nil { + if bhID, ok := settings["app.business_hours_id"].(string); ok { + response.DefaultBusinessHoursID, _ = strconv.Atoi(bhID) + } + } + } + } + + return r.SendEnvelope(response) } // handleChatInit initializes a new chat session. @@ -128,7 +197,6 @@ func handleChatInit(r *fastglue.Request) error { if !inbox.Enabled { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError) } - if inbox.Channel != livechat.ChannelLiveChat { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError) } @@ -139,11 +207,11 @@ func handleChatInit(r *fastglue.Request) error { app.lo.Error("error parsing live chat config", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) } - - var contactID int - var conversationUUID string - var isVisitor bool - + var ( + contactID int + conversationUUID string + isVisitor bool + ) // Handle authenticated user if req.JWT != "" { claims, err := verifyStandardJWT(req.JWT) @@ -174,6 +242,36 @@ func handleChatInit(r *fastglue.Request) error { } } + // Check conversation permissions based on user type. + userConfig := config.Visitors + if !isVisitor { + userConfig = config.Users + } + + if !userConfig.AllowStartConversation { + return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("globals.messages.notAllowed}"), nil, envelope.PermissionError) + } + + if userConfig.PreventMultipleConversations { + conversations, err := app.conversation.GetContactChatConversations(contactID) + if err != nil { + userType := "visitor" + if !isVisitor { + userType = "user" + } + app.lo.Error("error fetching "+userType+" conversations", "contact_id", contactID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil, envelope.GeneralError) + } + if len(conversations) > 0 { + userType := "visitor" + if !isVisitor { + userType = "user" + } + app.lo.Info(userType+" attempted to start new conversation but already has one", "contact_id", contactID, "conversations_count", len(conversations)) + return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("globals.messages.notAllowed}"), nil, envelope.PermissionError) + } + } + app.lo.Info("creating new live chat conversation for user", "user_id", contactID, "inbox_id", req.InboxID, "is_visitor", isVisitor) // Create conversation. @@ -191,14 +289,14 @@ func handleChatInit(r *fastglue.Request) error { } // Insert initial message. - message := models.Message{ + message := cmodels.Message{ ConversationUUID: conversationUUID, SenderID: contactID, - Type: models.MessageIncoming, - SenderType: models.SenderTypeContact, - Status: models.MessageStatusReceived, + Type: cmodels.MessageIncoming, + SenderType: cmodels.SenderTypeContact, + Status: cmodels.MessageStatusReceived, Content: req.Message, - ContentType: models.ContentTypeText, + ContentType: cmodels.ContentTypeText, Private: false, } if err := app.conversation.InsertMessage(&message); err != nil { @@ -211,6 +309,11 @@ func handleChatInit(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError) } + // Process post-message hooks for the new conversation and initial message. + if err := app.conversation.ProcessIncomingMessageHooks(conversationUUID, true); err != nil { + app.lo.Error("error processing incoming message hooks for initial message", "conversation_uuid", conversationUUID, "error", err) + } + conversation, err := app.conversation.GetConversation(0, conversationUUID) if err != nil { app.lo.Error("error fetching created conversation", "conversation_uuid", conversationUUID, "error", err) @@ -218,15 +321,17 @@ func handleChatInit(r *fastglue.Request) error { } // Build response with conversation and messages. - resp, err := buildConversationResponse(app, conversation) + resp, err := buildConversationResponseWithBusinessHours(app, conversation) if err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(map[string]any{ - "conversation": resp.Conversation, - "messages": resp.Messages, - "jwt": req.JWT, + "conversation": resp.Conversation, + "messages": resp.Messages, + "business_hours_id": resp.BusinessHoursID, + "working_hours_utc_offset": resp.WorkingHoursUTCOffset, + "jwt": req.JWT, }) } @@ -323,7 +428,7 @@ func handleChatGetConversation(r *fastglue.Request) error { } // Build conversation response with messages and attachments. - resp, err := buildConversationResponse(app, conversation) + resp, err := buildConversationResponseWithBusinessHours(app, conversation) if err != nil { return sendErrorEnvelope(r, err) } @@ -366,7 +471,7 @@ func handleChatSendMessage(r *fastglue.Request) error { app = r.Context.(*App) conversationUUID = r.RequestCtx.UserValue("uuid").(string) req = chatMessageReq{} - senderType = models.SenderTypeContact + senderType = cmodels.SenderTypeContact senderID = 0 ) @@ -386,6 +491,13 @@ func handleChatSendMessage(r *fastglue.Request) error { } senderID = claims.UserID + // Fetch sender. + sender, err := app.user.Get(senderID, "", "") + if err != nil { + app.lo.Error("error fetching sender user", "sender_id", senderID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + // Fetch conversation to ensure it exists. conversation, err := app.conversation.GetConversation(0, conversationUUID) if err != nil { @@ -408,31 +520,57 @@ func handleChatSendMessage(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError) } - // Insert message. - message := models.Message{ + // Insert incoming message and run post processing hooks. + message := cmodels.Message{ ConversationUUID: conversationUUID, SenderID: senderID, - Type: models.MessageIncoming, + Type: cmodels.MessageIncoming, SenderType: senderType, - Status: models.MessageStatusReceived, + Status: cmodels.MessageStatusReceived, Content: req.Message, - ContentType: models.ContentTypeText, + ContentType: cmodels.ContentTypeText, Private: false, } - - if err := app.conversation.InsertMessage(&message); err != nil { - app.lo.Error("error inserting chat message", "error", err) + if message, err = app.conversation.ProcessIncomingMessage(cmodels.IncomingMessage{ + Channel: livechat.ChannelLiveChat, + Message: message, + Contact: sender, + InboxID: inbox.ID, + }); err != nil { + app.lo.Error("error processing incoming message", "conversation_uuid", conversationUUID, "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError) } - return r.SendEnvelope(map[string]bool{"success": true}) + // Fetch just inserted message to return. + message, err = app.conversation.GetMessage(message.UUID) + if err != nil { + app.lo.Error("error fetching inserted message", "message_uuid", message.UUID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.message}"), nil, envelope.GeneralError) + } + + return r.SendEnvelope(cmodels.ChatMessage{ + UUID: message.UUID, + CreatedAt: message.CreatedAt, + Content: message.Content, + TextContent: message.TextContent, + ConversationUUID: message.ConversationUUID, + Status: message.Status, + Author: umodels.ChatUser{ + ID: sender.ID, + FirstName: sender.FirstName, + LastName: sender.LastName, + AvatarURL: sender.AvatarURL, + AvailabilityStatus: sender.AvailabilityStatus, + Type: sender.Type, + }, + Attachments: message.Attachments, + }) } // handleWidgetMediaUpload handles media uploads for the widget. func handleWidgetMediaUpload(r *fastglue.Request) error { var ( app = r.Context.(*App) - cleanUp = false senderID = 0 ) @@ -466,14 +604,12 @@ func handleWidgetMediaUpload(r *fastglue.Request) error { // Set sender ID from JWT claims. senderID = claims.UserID - // Verify conversation exists and user has access + // Make sure the conversation belongs to the sender conversation, err := app.conversation.GetConversation(0, conversationUUID) if err != nil { app.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err) return sendErrorEnvelope(r, err) } - - // Make sure the conversation belongs to the sender if conversation.ContactID != senderID { app.lo.Error("access denied: user attempted to access conversation owned by different contact", "conversation_uuid", conversationUUID, "requesting_contact_id", senderID, "conversation_owner_id", conversation.ContactID) return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError) @@ -535,130 +671,65 @@ func handleWidgetMediaUpload(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("media.fileTypeNotAllowed"), nil, envelope.InputError) } - // Delete files on any error. - var uuid = uuid.New() - thumbName := thumbPrefix + uuid.String() - defer func() { - if cleanUp { - app.media.Delete(uuid.String()) - app.media.Delete(thumbName) - } - }() - - // Generate and upload thumbnail and store image dimensions in the media meta. - var meta = []byte("{}") - if slices.Contains(image.Exts, srcExt) { - file.Seek(0, 0) - thumbFile, err := image.CreateThumb(image.DefThumbSize, file) - if err != nil { - app.lo.Error("error creating thumb image", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.thumbnail}"), nil, envelope.GeneralError) - } - thumbName, err = app.media.Upload(thumbName, srcContentType, thumbFile) - if err != nil { - return sendErrorEnvelope(r, err) - } - - // Store image dimensions in media meta, storing dimensions for image previews in future. - file.Seek(0, 0) - width, height, err := image.GetDimensions(file) - if err != nil { - cleanUp = true - app.lo.Error("error getting image dimensions", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.media}"), nil, envelope.GeneralError) - } - meta, _ = json.Marshal(map[string]any{ - "width": width, - "height": height, - }) - } - + // Read file content into byte slice file.Seek(0, 0) - _, err = app.media.Upload(uuid.String(), srcContentType, file) - if err != nil { - cleanUp = true - app.lo.Error("error uploading file", "error", err) - return sendErrorEnvelope(r, err) + fileContent := make([]byte, srcFileSize) + if _, err := file.Read(fileContent); err != nil { + app.lo.Error("error reading file content", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil, envelope.GeneralError) } - // Insert message with empty content and after insert link the media to the message. - message := models.Message{ + // Get sender user for ProcessIncomingMessage + sender, err := app.user.Get(senderID, "", "") + if err != nil { + app.lo.Error("error fetching sender user", "sender_id", senderID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + + // Create message with attachment using existing infrastructure + message := cmodels.Message{ ConversationUUID: conversationUUID, SenderID: senderID, - Type: models.MessageIncoming, - SenderType: models.SenderTypeContact, - Status: models.MessageStatusReceived, + Type: cmodels.MessageIncoming, + SenderType: cmodels.SenderTypeContact, + Status: cmodels.MessageStatusReceived, Content: "", - ContentType: models.ContentTypeText, + ContentType: cmodels.ContentTypeText, Private: false, - } - if err := app.conversation.InsertMessage(&message); err != nil { - cleanUp = true - app.lo.Error("error inserting message", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError) + Attachments: attachment.Attachments{ + { + Name: srcFileName, + ContentType: srcContentType, + Size: int(srcFileSize), + Content: fileContent, + Disposition: attachment.DispositionAttachment, + }, + }, } - // Insert media linked to the just inserted message. - media, err := app.media.Insert(null.StringFrom(attachment.DispositionAttachment), srcFileName, srcContentType, "" /**content_id**/, null.NewString("messages", true), uuid.String(), null.NewInt(message.ID, true), int(srcFileSize), meta) + // Process the incoming message with attachment. + if message, err = app.conversation.ProcessIncomingMessage(cmodels.IncomingMessage{ + Channel: livechat.ChannelLiveChat, + Message: message, + Contact: sender, + InboxID: inbox.ID, + }); err != nil { + app.lo.Error("error processing incoming message with attachment", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorInserting", "name", "{globals.terms.message}"), nil, envelope.GeneralError) + } + + // Fetch the inserted message to get the media information. + insertedMessage, err := app.conversation.GetMessage(message.UUID) if err != nil { - cleanUp = true - app.lo.Error("error inserting metadata into database", "error", err) - return sendErrorEnvelope(r, err) + app.lo.Error("error fetching inserted message", "message_uuid", message.UUID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.message}"), nil, envelope.GeneralError) } - return r.SendEnvelope(media) -} - -// handleWidgetServeMedia serves media files for the widget -func handleWidgetServeMedia(r *fastglue.Request) error { - var ( - app = r.Context.(*App) - uuid = r.RequestCtx.UserValue("uuid").(string) - signature = string(r.RequestCtx.QueryArgs().Peek("signature")) - expiresStr = string(r.RequestCtx.QueryArgs().Peek("expires")) - ) - - if uuid == "" { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "uuid"), nil, envelope.InputError) - } - - if signature == "" { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Signature missing", nil, envelope.InputError) - } - - if expiresStr == "" { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Expiry missing", nil, envelope.InputError) - } - - expires, err := strconv.ParseInt(expiresStr, 10, 64) - if err != nil { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid expiry", nil, envelope.InputError) - } - - // Verify signature and expiration. - expiresAt := time.Unix(expires, 0) - if !VerifySignedURL(uuid, signature, expiresAt, getJWTSecret()) { - return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) - } - - // Get media DB record. - _, err = app.media.Get(0, uuid) - if err != nil { - return sendErrorEnvelope(r, err) - } - - consts := app.consts.Load().(*constants) - switch consts.UploadProvider { - case "fs": - fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid)) - case "s3": - r.RequestCtx.Redirect(app.media.GetURL(uuid), http.StatusFound) - } - return nil + return r.SendEnvelope(insertedMessage) } // buildConversationResponse builds the response for a conversation including its messages -func buildConversationResponse(app *App, conversation models.Conversation) (conversationResp, error) { +func buildConversationResponse(app *App, conversation cmodels.Conversation) (conversationResp, error) { var resp = conversationResp{} // Fetch last 1000 messages, this should suffice as chats shouldn't have too many messages. @@ -669,22 +740,48 @@ func buildConversationResponse(app *App, conversation models.Conversation) (conv return resp, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.message}"), nil) } + app.conversation.ProcessCSATStatus(messages) + // Convert to chat message format, Generate signed widget URL for all attachments - expires in 1 hour. - chatMessages := make([]models.ChatMessage, len(messages)) + chatMessages := make([]cmodels.ChatMessage, len(messages)) + userCache := make(map[int]umodels.User) for i, msg := range messages { attachments := msg.Attachments for j := range attachments { - expiresAt := time.Now().Add(1 * time.Hour) - signature := GenerateSignedURL(attachments[j].UUID, expiresAt, getJWTSecret()) - attachments[j].URL = fmt.Sprintf(chatWidgetMediaURL, attachments[j].UUID, signature, expiresAt.Unix()) + expiresAt := time.Now().Add(8 * time.Hour) + attachments[j].URL = app.media.GetSignedURL(attachments[j].UUID, expiresAt) } - chatMessages[i] = models.ChatMessage{ - UUID: msg.UUID, - Content: msg.Content, - CreatedAt: msg.CreatedAt, - SenderType: msg.SenderType, - ConversationID: msg.ConversationUUID, - Attachments: attachments, + + // Check if sender is cached, if not fetch from user store. + var user umodels.User + if _, ok := userCache[msg.SenderID]; !ok { + user, err = app.user.Get(msg.SenderID, "", "") + if err != nil { + app.lo.Error("error fetching message sender user", "sender_id", msg.SenderID, "conversation_uuid", conversation.UUID, "error", err) + } else { + userCache[msg.SenderID] = user + } + } else { + user = userCache[msg.SenderID] + } + + chatMessages[i] = cmodels.ChatMessage{ + UUID: msg.UUID, + Status: msg.Status, + CreatedAt: msg.CreatedAt, + Content: msg.Content, + TextContent: msg.TextContent, + ConversationUUID: msg.ConversationUUID, + Meta: msg.Meta, + Author: umodels.ChatUser{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + AvatarURL: user.AvatarURL, + AvailabilityStatus: user.AvailabilityStatus, + Type: user.Type, + }, + Attachments: attachments, } } @@ -698,24 +795,32 @@ func buildConversationResponse(app *App, conversation models.Conversation) (conv } // Convert assignee avatar URL to widget format if set. - // TODO: Instead of this hardcoded URL, make it a central handler. if assignee.AvatarURL.Valid && assignee.AvatarURL.String != "" { avatarPath := assignee.AvatarURL.String if strings.HasPrefix(avatarPath, "/uploads/") { avatarUUID := strings.TrimPrefix(avatarPath, "/uploads/") // Generate signed URL for avatar with 1 hour expiry expiresAt := time.Now().Add(1 * time.Hour) - signature := GenerateSignedURL(avatarUUID, expiresAt, getJWTSecret()) - assignee.AvatarURL = null.StringFrom(fmt.Sprintf(chatWidgetMediaURL, avatarUUID, signature, expiresAt.Unix())) + assignee.AvatarURL = null.StringFrom(app.media.GetSignedURL(avatarUUID, expiresAt)) } } } resp = conversationResp{ - Conversation: models.ChatConversation{ - UUID: conversation.UUID, - Status: conversation.Status.String, - Assignee: assignee, + Conversation: cmodels.ChatConversation{ + CreatedAt: assignee.CreatedAt, + UUID: conversation.UUID, + Status: conversation.Status.String, + UnreadMessageCount: conversation.UnreadMessageCount, + Assignee: umodels.ChatUser{ + ID: assignee.ID, + FirstName: assignee.FirstName, + LastName: assignee.LastName, + AvatarURL: assignee.AvatarURL, + AvailabilityStatus: assignee.AvailabilityStatus, + Type: assignee.Type, + ActiveAt: assignee.LastActiveAt, + }, }, Messages: chatMessages, } @@ -723,33 +828,64 @@ func buildConversationResponse(app *App, conversation models.Conversation) (conv return resp, nil } -// GenerateSignedURL generates a signature for media access with expiration -func GenerateSignedURL(uuid string, expiresAt time.Time, secret []byte) string { - exp := expiresAt.Unix() - payload := fmt.Sprintf("%s:%d", uuid, exp) - sig := hmacSha256(payload, secret) - return sig -} - -// VerifySignedURL verifies a signed URL with expiration -func VerifySignedURL(uuid, signature string, expiresAt time.Time, secret []byte) bool { - // Check if expired - if time.Now().After(expiresAt) { - return false +// buildConversationResponseWithBusinessHours builds conversation response with business hours info +func buildConversationResponseWithBusinessHours(app *App, conversation cmodels.Conversation) (conversationResponseWithBusinessHours, error) { + baseResp, err := buildConversationResponse(app, conversation) + if err != nil { + return conversationResponseWithBusinessHours{}, err } - // Generate expected signature - expectedSignature := GenerateSignedURL(uuid, expiresAt, secret) + resp := conversationResponseWithBusinessHours{ + conversationResp: baseResp, + } - // Compare signatures using constant time comparison to prevent timing attacks - return hmac.Equal([]byte(signature), []byte(expectedSignature)) -} + // Calculate business hours info if assigned to team or use default + var businessHoursID *int + var timezone string -// hmacSha256 generates HMAC-SHA256 hash -func hmacSha256(data string, secret []byte) string { - h := hmac.New(sha256.New, secret) - h.Write([]byte(data)) - return hex.EncodeToString(h.Sum(nil)) + // Check if conversation is assigned to a team with business hours + if conversation.AssignedTeamID.Valid { + team, err := app.team.Get(conversation.AssignedTeamID.Int) + if err == nil && team.BusinessHoursID.Valid { + businessHoursID = &team.BusinessHoursID.Int + timezone = team.Timezone + } + } + + // Fallback to general settings if no team business hours + if businessHoursID == nil { + out, err := app.setting.GetByPrefix("app") + if err == nil { + var settings map[string]interface{} + if err := json.Unmarshal(out, &settings); err == nil { + if bhIDStr, ok := settings["app.business_hours_id"].(string); ok && bhIDStr != "" { + // Parse the business hours ID + if bhID, err := strconv.Atoi(bhIDStr); err == nil { + businessHoursID = &bhID + } + } + if tz, ok := settings["app.timezone"].(string); ok { + timezone = tz + } + } + } + } + + // Set business hours info in response + if businessHoursID != nil { + resp.BusinessHoursID = businessHoursID + + // Calculate UTC offset for the timezone + if timezone != "" { + if loc, err := time.LoadLocation(timezone); err == nil { + _, offset := time.Now().In(loc).Zone() + offsetMinutes := offset / 60 // Convert seconds to minutes + resp.WorkingHoursUTCOffset = &offsetMinutes + } + } + } + + return resp, nil } // verifyJWT verifies and validates a JWT token with proper signature verification diff --git a/cmd/conversation.go b/cmd/conversation.go index 9e422b8d..4a8ceecb 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -474,11 +474,6 @@ func handleUpdateConversationStatus(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - // Make sure a user is assigned before resolving conversation. - if status == cmodels.StatusResolved && conversation.AssignedUserID.Int == 0 { - return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("conversation.resolveWithoutAssignee"), nil)) - } - // Update conversation status. if err := app.conversation.UpdateConversationStatus(uuid, 0 /**status_id**/, status, snoozedUntil, user); err != nil { return sendErrorEnvelope(r, err) diff --git a/cmd/csat.go b/cmd/csat.go index a89234cc..4f61583d 100644 --- a/cmd/csat.go +++ b/cmd/csat.go @@ -3,9 +3,16 @@ package main import ( "strconv" + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" ) +type csatResponse struct { + Rating int `json:"rating"` + Feedback string `json:"feedback"` +} + // handleShowCSAT renders the CSAT page for a given csat. func handleShowCSAT(r *fastglue.Request) error { var ( @@ -42,7 +49,7 @@ func handleShowCSAT(r *fastglue.Request) error { return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{ "Data": map[string]interface{}{ - "Title": "Rate your interaction with us", + "Title": "Rate your interaction with us", "CSAT": map[string]interface{}{ "UUID": csat.UUID, }, @@ -72,7 +79,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error { }) } - if ratingI < 1 || ratingI > 5 { + if ratingI < 0 || ratingI > 5 { return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ "Data": map[string]interface{}{ "ErrorMessage": "Invalid `rating`", @@ -103,3 +110,36 @@ func handleUpdateCSATResponse(r *fastglue.Request) error { }, }) } + +// handleSubmitCSATResponse handles CSAT response submission from the widget API. +func handleSubmitCSATResponse(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + uuid = r.RequestCtx.UserValue("uuid").(string) + req = csatResponse{} + ) + + if err := r.Decode(&req, "json"); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid JSON", nil, envelope.InputError) + } + + if req.Rating < 0 || req.Rating > 5 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Rating must be between 0 and 5 (0 means no rating)", nil, envelope.InputError) + } + + // At least one of rating or feedback must be provided + if req.Rating == 0 && req.Feedback == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Either rating or feedback must be provided", nil, envelope.InputError) + } + + if uuid == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid UUID", nil, envelope.InputError) + } + + // Update CSAT response + if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback); err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(true) +} diff --git a/cmd/handlers.go b/cmd/handlers.go index 27c7c3fa..f1d2a2bd 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -210,21 +210,26 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // Actvity logs. g.GET("/api/v1/activity-logs", perm(handleGetActivityLogs, "activity_logs:manage")) + // CSAT. + g.POST("/api/v1/csat/{uuid}/response", handleSubmitCSATResponse) + // WebSocket. g.GET("/ws", auth(func(r *fastglue.Request) error { return handleWS(r, hub) })) - // Live chat widget. + // Live chat widget websocket. g.GET("/widget/ws", handleWidgetWS) - g.GET("/api/v1/widget/chat/settings", handleGetChatSettings) - g.POST("/api/v1/widget/chat/conversations/init", handleChatInit) - g.POST("/api/v1/widget/chat/conversations", handleGetConversations) - g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", handleChatUpdateLastSeen) - g.POST("/api/v1/widget/chat/conversations/{uuid}", handleChatGetConversation) - g.POST("/api/v1/widget/chat/conversations/{uuid}/message", handleChatSendMessage) - g.POST("/api/v1/widget/media/upload", handleWidgetMediaUpload) - g.GET("/api/v1/widget/media/{uuid}", handleWidgetServeMedia) + + // Widget APIs. + g.GET("/api/v1/widget/chat/settings/launcher", widgetOrigin(handleGetChatLauncherSettings)) + g.GET("/api/v1/widget/chat/settings", widgetOrigin(handleGetChatSettings)) + g.POST("/api/v1/widget/chat/conversations/init", widgetOrigin(handleChatInit)) + g.POST("/api/v1/widget/chat/conversations", widgetOrigin(handleGetConversations)) + g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", widgetOrigin(handleChatUpdateLastSeen)) + g.POST("/api/v1/widget/chat/conversations/{uuid}", widgetOrigin(handleChatGetConversation)) + g.POST("/api/v1/widget/chat/conversations/{uuid}/message", widgetOrigin(handleChatSendMessage)) + g.POST("/api/v1/widget/media/upload", widgetOrigin(handleWidgetMediaUpload)) // Frontend pages. g.GET("/", notAuthPage(serveIndexPage)) diff --git a/cmd/inboxes.go b/cmd/inboxes.go index 1bac4c7b..2bf4bc04 100644 --- a/cmd/inboxes.go +++ b/cmd/inboxes.go @@ -1,10 +1,12 @@ package main import ( + "encoding/json" "net/mail" "strconv" "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" imodels "github.com/abhinavxd/libredesk/internal/inbox/models" "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" @@ -169,5 +171,17 @@ func validateInbox(app *App, inbox imodels.Inbox) error { if inbox.Channel == "" { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "channel"), nil) } + + // Validate livechat-specific configuration + if inbox.Channel == livechat.ChannelLiveChat { + var config livechat.Config + if err := json.Unmarshal(inbox.Config, &config); err == nil { + // ShowOfficeHoursAfterAssignment cannot be enabled if ShowOfficeHoursInChat is disabled + if config.ShowOfficeHoursAfterAssignment && !config.ShowOfficeHoursInChat { + return envelope.NewError(envelope.InputError, "`show_office_hours_after_assignment` cannot be enabled when `show_office_hours_in_chat` is disabled", nil) + } + } + } + return nil } diff --git a/cmd/init.go b/cmd/init.go index 29301e89..b88aed9e 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -462,10 +462,11 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager { } media, err := media.New(media.Opts{ - Store: store, - Lo: lo, - DB: db, - I18n: i18n, + Store: store, + Lo: lo, + DB: db, + I18n: i18n, + Secret: ko.String("upload.secret"), }) if err != nil { log.Fatalf("error initializing media: %v", err) diff --git a/cmd/main.go b/cmd/main.go index 496e2d5b..bc2f4149 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -203,9 +203,13 @@ func main() { conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template, webhook) autoassigner = initAutoAssigner(team, user, conversation) ) + + wsHub.SetConversationStore(conversation) automation.SetConversationStore(conversation) + // Start inboxes. startInboxes(ctx, inbox, conversation, user) + go automation.Run(ctx, automationWorkers) go autoassigner.Run(ctx, autoAssignInterval) go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval) diff --git a/cmd/media.go b/cmd/media.go index f953d5de..f3764649 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -143,45 +143,51 @@ func handleMediaUpload(r *fastglue.Request) error { } // handleServeMedia serves uploaded media. +// Supports both authenticated agent access and unauthenticated access via signed URLs. func handleServeMedia(r *fastglue.Request) error { var ( - app = r.Context.(*App) - auser = r.RequestCtx.UserValue("user").(amodels.User) - uuid = r.RequestCtx.UserValue("uuid").(string) + app = r.Context.(*App) + uuid = r.RequestCtx.UserValue("uuid").(string) ) - user, err := app.user.GetAgent(auser.ID, "") - if err != nil { - return sendErrorEnvelope(r, err) - } - - // Fetch media from DB. - media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix)) - if err != nil { - return sendErrorEnvelope(r, err) - } - - // Check if the user has permission to access the linked model. - allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String) - if err != nil { - return sendErrorEnvelope(r, err) - } - - // For messages, check access to the conversation this message is part of. - if media.Model.String == "messages" { - conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int) + // Check if user is authenticated (agent access) + auser := r.RequestCtx.UserValue("user") + if auser != nil { + // Authenticated. + user, err := app.user.GetAgent(auser.(amodels.User).ID, "") if err != nil { return sendErrorEnvelope(r, err) } - allowed, err = app.authz.EnforceConversationAccess(user, conversation) + + // Fetch media from DB. + media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix)) if err != nil { return sendErrorEnvelope(r, err) } - } - if !allowed { - return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError) + // Check if the user has permission to access the linked model. + allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String) + if err != nil { + return sendErrorEnvelope(r, err) + } + + // For messages, check access to the conversation this message is part of. + if media.Model.String == "messages" { + conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int) + if err != nil { + return sendErrorEnvelope(r, err) + } + allowed, err = app.authz.EnforceConversationAccess(user, conversation) + if err != nil { + return sendErrorEnvelope(r, err) + } + } + + if !allowed { + return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError) + } } + // If no authenticated user, the middleware has already verified the request signature serve the file. consts := app.consts.Load().(*constants) switch consts.UploadProvider { case "fs": diff --git a/cmd/messages.go b/cmd/messages.go index 9571bd7d..02fb75bd 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -53,10 +53,11 @@ func handleGetMessages(r *fastglue.Request) error { for j := range messages[i].Attachments { messages[i].Attachments[j].URL = app.media.GetURL(messages[i].Attachments[j].UUID) } - // Redact CSAT survey link - messages[i].CensorCSATContent() } + // Process CSAT status for all messages (will only affect CSAT messages) + app.conversation.ProcessCSATStatus(messages) + return r.SendEnvelope(envelope.PageResults{ Total: total, Results: messages, @@ -90,8 +91,10 @@ func handleGetMessage(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - // Redact CSAT survey link - message.CensorCSATContent() + // Process CSAT status for the message (will only affect CSAT messages) + messages := []cmodels.Message{message} + app.conversation.ProcessCSATStatus(messages) + message = messages[0] for j := range message.Attachments { message.Attachments[j].URL = app.media.GetURL(message.Attachments[j].UUID) diff --git a/cmd/middlewares.go b/cmd/middlewares.go index b56ca5c4..4b6d9ae2 100644 --- a/cmd/middlewares.go +++ b/cmd/middlewares.go @@ -97,6 +97,23 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { return func(r *fastglue.Request) error { var app = r.Context.(*App) + // For media uploads, check if signature is provided in the query parameters, if so, verify it. + path := string(r.RequestCtx.Path()) + if strings.HasPrefix(path, "/uploads/") { + signature := string(r.RequestCtx.QueryArgs().Peek("signature")) + expires := string(r.RequestCtx.QueryArgs().Peek("expires")) + + if signature != "" && expires != "" { + if err := app.media.VerifySignature(r); err != nil { + app.lo.Error("error verifying media signature", "error", + err, "path", string(r.RequestCtx.Path()), "query", string(r.RequestCtx.QueryArgs().QueryString())) + return r.SendErrorEnvelope(http.StatusUnauthorized, "signature verification failed", nil, envelope.PermissionError) + } + return handler(r) + } + // If no signature, continue with normal authentication. + } + // Authenticate user using shared authentication logic user, err := authenticateUser(r, app) if err != nil { diff --git a/cmd/widget_middleware.go b/cmd/widget_middleware.go new file mode 100644 index 00000000..1adf8143 --- /dev/null +++ b/cmd/widget_middleware.go @@ -0,0 +1,106 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/httputil" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" + "github.com/zerodha/fastglue" +) + +type inboxReq struct { + InboxID int `json:"inbox_id"` +} + +// widgetOrigin middleware validates the Origin header against trusted domains configured in the live chat inbox settings. +func widgetOrigin(next fastglue.FastRequestHandler) fastglue.FastRequestHandler { + return func(r *fastglue.Request) error { + app := r.Context.(*App) + + // Get the Origin header from the request + origin := string(r.RequestCtx.Request.Header.Peek("Origin")) + + // If no origin header is present, allow direct access. + if origin == "" { + return next(r) + } + + // Extract inbox ID from request + var inboxID int + + // Search for inbox_id in query parameters first. + if qInboxID := r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id"); qInboxID > 0 { + inboxID = qInboxID + } else { + // For POST/PUT requests, try to decode the body to get `inbox_id`, every request sends it. + if r.RequestCtx.IsPost() || r.RequestCtx.IsPut() { + inboxReq := inboxReq{} + bodyBytes := r.RequestCtx.Request.Body() + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, &inboxReq); err == nil { + inboxID = inboxReq.InboxID + } + } + } + + // Check multipart form data for `inbox_id` if not found in query or body. + if inboxID == 0 { + if r.RequestCtx.Request.Header.IsPost() { + form, err := r.RequestCtx.MultipartForm() + if err == nil && form != nil && form.Value != nil { + if inboxIDValues, exists := form.Value["inbox_id"]; exists && len(inboxIDValues) > 0 { + if parsedID, parseErr := strconv.Atoi(inboxIDValues[0]); parseErr == nil && parsedID > 0 { + inboxID = parsedID + } + } + } + } + } + } + + // If we can't determine the inbox ID, disallow the request + if inboxID <= 0 { + app.lo.Warn("widget request without inbox_id blocked", "path", string(r.RequestCtx.Path())) + return r.SendErrorEnvelope(http.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + // Get inbox configuration + inbox, err := app.inbox.GetDBRecord(inboxID) + if err != nil { + app.lo.Error("error fetching inbox for origin check", "inbox_id", inboxID, "error", err) + return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.NotFoundError) + } + + if !inbox.Enabled { + return r.SendErrorEnvelope(http.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + // Parse the live chat config + var config livechat.Config + if err := json.Unmarshal(inbox.Config, &config); err != nil { + app.lo.Error("error parsing live chat config for origin check", "error", err) + return r.SendErrorEnvelope(http.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) + } + + // If trusted domains list is empty, allow all origins + if len(config.TrustedDomains) == 0 { + return next(r) + } + + // Check if the origin matches any of the trusted domains + if !httputil.IsOriginTrusted(origin, config.TrustedDomains) { + app.lo.Warn("widget request from untrusted origin blocked", + "origin", origin, + "inbox_id", inboxID, + "trusted_domains", config.TrustedDomains) + return r.SendErrorEnvelope(http.StatusForbidden, "Widget not allowed from this origin: "+origin, nil, envelope.PermissionError) + } + + app.lo.Debug("widget request from trusted origin allowed", "origin", origin, "inbox_id", inboxID) + + return next(r) + } +} diff --git a/cmd/widget_ws.go b/cmd/widget_ws.go index ce917dae..170b79e2 100644 --- a/cmd/widget_ws.go +++ b/cmd/widget_ws.go @@ -24,14 +24,13 @@ const ( // WidgetMessage represents a message sent through the widget WebSocket type WidgetMessage struct { - Type string `json:"type"` - JWT string `json:"jwt,omitempty"` - Data interface{} `json:"data"` + Type string `json:"type"` + JWT string `json:"jwt,omitempty"` + Data any `json:"data"` } -// WidgetJoinData represents data for joining a conversation -type WidgetJoinData struct { - ConversationUUID string `json:"conversation_uuid"` +type WidgetInboxJoinRequest struct { + InboxID int `json:"inbox_id"` } // WidgetMessageData represents a chat message through the widget @@ -91,11 +90,11 @@ func handleWidgetWS(r *fastglue.Request) error { } switch msg.Type { - // Join conversation request. + // Inbox join request. case WidgetMsgTypeJoin: var joinedClient *livechat.Client var joinedLiveChat *livechat.LiveChat - if joinedClient, joinedLiveChat, err = handleWidgetJoin(app, conn, &msg, claims); err != nil { + if joinedClient, joinedLiveChat, err = handleInboxJoin(app, conn, &msg, claims); err != nil { app.lo.Error("error handling widget join", "error", err) sendWidgetError(conn, "Failed to join conversation") continue @@ -124,8 +123,8 @@ func handleWidgetWS(r *fastglue.Request) error { return nil } -// handleWidgetJoin handles a client joining a conversation -func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims Claims) (*livechat.Client, *livechat.LiveChat, error) { +// handleInboxJoin handles a websocket join request for a live chat inbox. +func handleInboxJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims Claims) (*livechat.Client, *livechat.LiveChat, error) { userID := claims.UserID joinDataBytes, err := json.Marshal(msg.Data) @@ -133,28 +132,16 @@ func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims return nil, nil, fmt.Errorf("invalid join data: %w", err) } - var joinData WidgetJoinData + var joinData WidgetInboxJoinRequest if err := json.Unmarshal(joinDataBytes, &joinData); err != nil { return nil, nil, fmt.Errorf("invalid join data format: %w", err) } - // Get conversation to find the inbox - conversation, err := app.conversation.GetConversation(0, joinData.ConversationUUID) - if err != nil { - return nil, nil, fmt.Errorf("conversation not found: %w", err) - } - - // Make sure conversation belongs to the user. - if conversation.ContactID != userID { - return nil, nil, fmt.Errorf("conversation does not belong to the user") - } - // Make sure inbox is active. - inbox, err := app.inbox.GetDBRecord(conversation.InboxID) + inbox, err := app.inbox.GetDBRecord(joinData.InboxID) if err != nil { return nil, nil, fmt.Errorf("inbox not found: %w", err) } - if !inbox.Enabled { return nil, nil, fmt.Errorf("inbox is not enabled") } @@ -173,9 +160,9 @@ func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims // Add client to live chat session userIDStr := fmt.Sprintf("%d", userID) - client, err := liveChat.AddClient(userIDStr, joinData.ConversationUUID) + client, err := liveChat.AddClient(userIDStr) if err != nil { - app.lo.Error("error adding client to live chat", "error", err, "user_id", userIDStr, "conversation_uuid", joinData.ConversationUUID) + app.lo.Error("error adding client to live chat", "error", err, "user_id", userIDStr) return nil, nil, err } @@ -193,8 +180,7 @@ func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims joinResp := WidgetMessage{ Type: WidgetMsgTypeJoined, Data: map[string]string{ - "message": "Joined conversation successfully", - "conversation_uuid": joinData.ConversationUUID, + "message": "namaste!", }, } @@ -202,6 +188,8 @@ func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims return nil, nil, err } + app.lo.Debug("widget client joined live chat", "user_id", userIDStr, "inbox_id", joinData.InboxID) + return client, liveChat, nil } @@ -219,8 +207,14 @@ func handleWidgetTyping(app *App, msg *WidgetMessage, claims Claims) error { app.lo.Error("error unmarshalling typing data", "error", err) return fmt.Errorf("invalid typing data format: %w", err) } - // Broadcast this to agents. - app.lo.Debug("Received typing data for user", "user_id", userID, "is_typing", typingData.IsTyping, "conversation_uuid", typingData.ConversationUUID) + + // Broadcast typing status to agents via conversation manager + // Set broadcastToWidgets=false to avoid echoing back to widget clients + if typingData.ConversationUUID != "" { + app.conversation.BroadcastTypingToConversation(typingData.ConversationUUID, typingData.IsTyping, false) + } + + app.lo.Debug("Broadcasted typing data from widget user to agents", "user_id", userID, "is_typing", typingData.IsTyping, "conversation_uuid", typingData.ConversationUUID) return nil } diff --git a/frontend/apps/main/src/components/editor/TextEditor.vue b/frontend/apps/main/src/components/editor/TextEditor.vue index d67356b7..85ebd501 100644 --- a/frontend/apps/main/src/components/editor/TextEditor.vue +++ b/frontend/apps/main/src/components/editor/TextEditor.vue @@ -118,6 +118,8 @@ import Table from '@tiptap/extension-table' import TableRow from '@tiptap/extension-table-row' import TableCell from '@tiptap/extension-table-cell' import TableHeader from '@tiptap/extension-table-header' +import { useTypingIndicator } from '@shared-ui/composables' +import { useConversationStore } from '@main/stores/conversation' const textContent = defineModel('textContent', { default: '' }) const htmlContent = defineModel('htmlContent', { default: '' }) @@ -141,6 +143,10 @@ const emit = defineEmits(['send', 'aiPromptSelected']) const emitPrompt = (key) => emit('aiPromptSelected', key) +// Set up typing indicator +const conversationStore = useConversationStore() +const { startTyping, stopTyping } = useTypingIndicator(conversationStore.sendTyping) + // To preseve the table styling in emails, need to set the table style inline. // Created these custom extensions to set the table style inline. const CustomTable = Table.extend({ @@ -201,6 +207,8 @@ const editor = useEditor({ handleKeyDown: (view, event) => { if (event.ctrlKey && event.key === 'Enter') { emit('send') + // Stop typing when sending + stopTyping() return true } } @@ -211,6 +219,13 @@ const editor = useEditor({ htmlContent.value = editor.getHTML() textContent.value = editor.getText() isInternalUpdate.value = false + + // Trigger typing indicator when user types + startTyping() + }, + onBlur: () => { + // Stop typing when editor loses focus + stopTyping() } }) diff --git a/frontend/apps/main/src/constants/websocket.js b/frontend/apps/main/src/constants/websocket.js index e6374cc1..3d20cd48 100644 --- a/frontend/apps/main/src/constants/websocket.js +++ b/frontend/apps/main/src/constants/websocket.js @@ -2,4 +2,12 @@ export const WS_EVENT = { NEW_MESSAGE: 'new_message', MESSAGE_PROP_UPDATE: 'message_prop_update', CONVERSATION_PROP_UPDATE: 'conversation_prop_update', -} \ No newline at end of file + CONVERSATION_SUBSCRIBE: 'conversation_subscribe', + CONVERSATION_SUBSCRIBED: 'conversation_subscribed', + TYPING: 'typing', +} + +// Message types that should not be queued because they become stale quickly +export const WS_EPHEMERAL_TYPES = [ + WS_EVENT.TYPING, +] \ No newline at end of file diff --git a/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue b/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue index 5f83999d..f3271b4e 100644 --- a/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue +++ b/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue @@ -1,519 +1,495 @@ - - - {{ $t('globals.terms.name') }} - - - - {{ $t('admin.inbox.name.description') }} - - - + + + + {{ $t('admin.inbox.livechat.tabs.general') }} + {{ + $t('admin.inbox.livechat.tabs.appearance') + }} + {{ $t('admin.inbox.livechat.tabs.messages') }} + {{ $t('admin.inbox.livechat.tabs.features') }} + {{ $t('admin.inbox.livechat.tabs.security') }} + {{ $t('admin.inbox.livechat.tabs.users') }} + - - - - {{ $t('globals.terms.enabled') }} - {{ $t('admin.inbox.enabled.description') }} - - - - - - - - - - - {{ $t('admin.inbox.csatSurveys') }} - - {{ $t('admin.inbox.csatSurveys.description_1') }} - {{ $t('admin.inbox.csatSurveys.description_2') }} - - - - - - - - - - - {{ $t('admin.inbox.livechatConfig') }} - - - - {{ $t('globals.terms.brandName') }} - - - - {{ $t('admin.inbox.livechat.brandName.description') }} - - - - - - - - {{ $t('admin.inbox.livechat.logoUrl') }} - - - - {{ $t('admin.inbox.livechat.logoUrl.description') }} - - - - - - - - - {{ $t('admin.inbox.livechat.secretKey') }} - - - - - - - - - {{ - $t('admin.inbox.livechat.secretKey.description') - }} - - - - - - - - {{ $t('admin.inbox.livechat.launcher') }} - - - - + + + + - {{ $t('admin.inbox.livechat.launcher.position') }} + {{ $t('globals.terms.name') }} + + + + {{ $t('admin.inbox.name.description') }} + + + + + + + + {{ $t('globals.terms.enabled') }} + {{ $t('admin.inbox.enabled.description') }} + + + + + + + + + + + {{ $t('admin.inbox.csatSurveys') }} + + {{ $t('admin.inbox.csatSurveys.description_1') }} + {{ $t('admin.inbox.csatSurveys.description_2') }} + + + + + + + + + + + {{ $t('globals.terms.brandName') }} + + + + + + + + + + {{ $t('globals.terms.language') }} - + - {{ - $t('admin.inbox.livechat.launcher.position.left') - }} - {{ - $t('admin.inbox.livechat.launcher.position.right') - }} + English + Marathi + {{ + $t('admin.inbox.livechat.language.description') + }} + + + + + + + + + + {{ $t('admin.inbox.livechat.logoUrl') }} + + + + {{ + $t('admin.inbox.livechat.logoUrl.description') + }} - - - - {{ $t('admin.inbox.livechat.launcher.logo') }} + + + {{ $t('admin.inbox.livechat.colors') }} + + + + {{ $t('admin.inbox.livechat.colors.primary') }} + + + + + + + + + + + + + + {{ $t('admin.inbox.livechat.darkMode') }} + {{ + $t('admin.inbox.livechat.darkMode.description') + }} + - + + + + + + + {{ $t('admin.inbox.livechat.launcher') }} + + + + + + {{ $t('admin.inbox.livechat.launcher.position') }} + + + + + + + {{ + $t('admin.inbox.livechat.launcher.position.left') + }} + {{ + $t('admin.inbox.livechat.launcher.position.right') + }} + + + + + + + + + + + {{ $t('admin.inbox.livechat.launcher.logo') }} + + + + + + + + + + + + + {{ $t('admin.inbox.livechat.launcher.spacing.side') }} + + + + {{ + $t('admin.inbox.livechat.launcher.spacing.side.description') + }} + + + + + + + + {{ $t('admin.inbox.livechat.launcher.spacing.bottom') }} + + + + {{ + $t('admin.inbox.livechat.launcher.spacing.bottom.description') + }} + + + + + + + + + + + + {{ $t('admin.inbox.livechat.greetingMessage') }} + + - - - - + - {{ $t('admin.inbox.livechat.launcher.spacing.side') }} + {{ $t('admin.inbox.livechat.introductionMessage') }} - + + + + + + + + + {{ $t('admin.inbox.livechat.chatIntroduction') }} + + {{ - $t('admin.inbox.livechat.launcher.spacing.side.description') + $t('admin.inbox.livechat.chatIntroduction.description') }} - - - - {{ $t('admin.inbox.livechat.launcher.spacing.bottom') }} - - - - {{ - $t('admin.inbox.livechat.launcher.spacing.bottom.description') - }} - - - + + + + {{ $t('admin.inbox.livechat.externalLinks') }} + + + + + + + + + + + + + + + + + + {{ $t('admin.inbox.livechat.externalLinks.add') }} + + + + {{ $t('admin.inbox.livechat.externalLinks.description') }} + + + + + - - - - {{ $t('admin.inbox.livechat.messages') }} + + + + + + {{ $t('admin.inbox.livechat.officeHours') }} + - - - {{ $t('admin.inbox.livechat.greetingMessage') }} - - - - - - + + + + {{ + $t('admin.inbox.livechat.showOfficeHoursInChat') + }} + {{ + $t('admin.inbox.livechat.showOfficeHoursInChat.description') + }} + + + + + + - - - {{ $t('admin.inbox.livechat.introductionMessage') }} - - - - - - - - - - {{ $t('admin.inbox.livechat.chatIntroduction') }} - - - - {{ - $t('admin.inbox.livechat.chatIntroduction.description') - }} - - - - - - - - {{ $t('admin.inbox.livechat.officeHours') }} - - - - - {{ - $t('admin.inbox.livechat.showOfficeHoursInChat') - }} - {{ - $t('admin.inbox.livechat.showOfficeHoursInChat.description') - }} - - - - - - - - - - - {{ - $t('admin.inbox.livechat.showOfficeHoursAfterAssignment') - }} - {{ - $t('admin.inbox.livechat.showOfficeHoursAfterAssignment.description') - }} - - - - - - - - - - - {{ $t('admin.inbox.livechat.noticeBanner') }} - - - - - {{ - $t('admin.inbox.livechat.noticeBanner.enabled') - }} - {{ - $t('admin.inbox.livechat.noticeBanner.enabled.description') - }} - - - - - - - - - - {{ $t('admin.inbox.livechat.noticeBanner.text') }} - - - - {{ - $t('admin.inbox.livechat.noticeBanner.text.description') - }} - - - - - - - - {{ $t('admin.inbox.livechat.colors') }} - - - - - {{ $t('admin.inbox.livechat.colors.primary') }} - - - - - - - - - - - - {{ $t('admin.inbox.livechat.features') }} - - - - - - {{ - $t('admin.inbox.livechat.features.fileUpload') - }} - {{ - $t('admin.inbox.livechat.features.fileUpload.description') - }} - - - - - - - - - - - {{ - $t('admin.inbox.livechat.features.emoji') - }} - {{ - $t('admin.inbox.livechat.features.emoji.description') - }} - - - - - - - - - - - - {{ $t('admin.inbox.livechat.externalLinks') }} - - - - - - - - + + + {{ + $t('admin.inbox.livechat.showOfficeHoursAfterAssignment') + }} + {{ + $t('admin.inbox.livechat.showOfficeHoursAfterAssignment.description') + }} + + + - - - - - + + + - - - {{ $t('admin.inbox.livechat.externalLinks.add') }} - - - - {{ $t('admin.inbox.livechat.externalLinks.description') }} - - - - - - - - - {{ $t('admin.inbox.livechat.trustedDomains') }} - - - - {{ $t('admin.inbox.livechat.trustedDomains.list') }} - - - - {{ - $t('admin.inbox.livechat.trustedDomains.description') - }} - - - - - - - - - {{ $t('admin.inbox.livechat.userSettings') }} - - - - - {{ $t('admin.inbox.livechat.userSettings.visitors') }} - - - {{ $t('admin.inbox.livechat.userSettings.users') }} - - - - - - - {{ $t('admin.inbox.livechat.startConversationButtonText') }} + {{ $t('admin.inbox.livechat.chatReplyExpectationMessage') }} - + + + {{ $t('admin.inbox.livechat.chatReplyExpectationMessage.description') }} + - - - - - {{ - $t('admin.inbox.livechat.allowStartConversation') - }} - {{ - $t('admin.inbox.livechat.allowStartConversation.visitors.description') - }} - - - - - - - - - - - {{ - $t('admin.inbox.livechat.preventMultipleConversations') - }} - {{ - $t('admin.inbox.livechat.preventMultipleConversations.visitors.description') - }} - - - - - - - - - + + + {{ $t('admin.inbox.livechat.features') }} + + + + + + {{ + $t('admin.inbox.livechat.features.fileUpload') + }} + {{ + $t('admin.inbox.livechat.features.fileUpload.description') + }} + + + + + + + + + + + {{ + $t('admin.inbox.livechat.features.emoji') + }} + {{ + $t('admin.inbox.livechat.features.emoji.description') + }} + + + + + + + + + + + + + + + - {{ $t('admin.inbox.livechat.startConversationButtonText') }} + {{ $t('admin.inbox.livechat.secretKey') }} - + + + + + + + {{ + $t('admin.inbox.livechat.secretKey.description') + }} + + + + + + {{ $t('admin.inbox.livechat.trustedDomains') }} + + + + + {{ $t('admin.inbox.livechat.trustedDomains.list') }} + + + + {{ + $t('admin.inbox.livechat.trustedDomains.description') + }} + + + + + + + + + {{ $t('admin.inbox.livechat.noticeBanner') }} + {{ - $t('admin.inbox.livechat.allowStartConversation') + $t('admin.inbox.livechat.noticeBanner.enabled') }} {{ - $t('admin.inbox.livechat.allowStartConversation.users.description') + $t('admin.inbox.livechat.noticeBanner.enabled.description') }} @@ -523,27 +499,157 @@ - - - {{ - $t('admin.inbox.livechat.preventMultipleConversations') - }} - {{ - $t('admin.inbox.livechat.preventMultipleConversations.users.description') - }} - + + {{ $t('admin.inbox.livechat.noticeBanner.text') }} - + + {{ + $t('admin.inbox.livechat.noticeBanner.text.description') + }} + - - + + + + + + + {{ $t('admin.inbox.livechat.userSettings.visitors') }} + + + {{ $t('admin.inbox.livechat.userSettings.users') }} + + + + + + + + + {{ + $t('admin.inbox.livechat.startConversationButtonText') + }} + + + + + + + + + + + {{ + $t('admin.inbox.livechat.allowStartConversation') + }} + {{ + $t('admin.inbox.livechat.allowStartConversation.visitors.description') + }} + + + + + + + + + + + {{ + $t('admin.inbox.livechat.preventMultipleConversations') + }} + {{ + $t('admin.inbox.livechat.preventMultipleConversations.visitors.description') + }} + + + + + + + + + + + + + {{ + $t('admin.inbox.livechat.startConversationButtonText') + }} + + + + + + + + + + + {{ + $t('admin.inbox.livechat.allowStartConversation') + }} + {{ + $t('admin.inbox.livechat.allowStartConversation.users.description') + }} + + + + + + + + + + + {{ + $t('admin.inbox.livechat.preventMultipleConversations') + }} + {{ + $t('admin.inbox.livechat.preventMultipleConversations.users.description') + }} + + + + + + + + + + + + {{ submitLabel }} @@ -599,6 +705,7 @@ const props = defineProps({ }) const { t } = useI18n() +const activeTab = ref('general') const selectedUserTab = ref('visitors') const externalLinks = ref([]) @@ -610,6 +717,8 @@ const form = useForm({ csat_enabled: false, config: { brand_name: '', + dark_mode: false, + language: 'en', logo_url: '', secret_key: '', launcher: { @@ -625,12 +734,13 @@ const form = useForm({ chat_introduction: 'Ask us anything, or share your feedback.', show_office_hours_in_chat: false, show_office_hours_after_assignment: false, + chat_reply_expectation_message: 'We typically reply in 5 minutes.', notice_banner: { enabled: false, text: 'Our response times are slower than usual. We regret the inconvenience caused.' }, colors: { - primary: '#2563eb', + primary: '#2563eb' }, features: { file_upload: true, @@ -663,7 +773,6 @@ const hasSecretKey = computed(() => { const copyToClipboard = async (text) => { try { await navigator.clipboard.writeText(text) - // You could emit a toast here for success feedback } catch (err) { console.error('Failed to copy text: ', err) } diff --git a/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js b/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js index 56b764d1..19b5e454 100644 --- a/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js +++ b/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js @@ -6,11 +6,21 @@ export const createFormSchema = (t) => z.object({ csat_enabled: z.boolean(), config: z.object({ brand_name: z.string().min(1, { message: t('globals.messages.required') }), - logo_url: z.string().url({ message: t('globals.messages.invalidUrl') }).optional().or(z.literal('')), + dark_mode: z.boolean(), + language: z.string().min(1, { message: t('globals.messages.required') }), + logo_url: z.string().url({ + message: t('globals.messages.invalid', { + name: t('globals.terms.url').toLowerCase() + }) + }).optional().or(z.literal('')), secret_key: z.string().optional(), launcher: z.object({ position: z.enum(['left', 'right']), - logo_url: z.string().url({ message: t('globals.messages.invalidUrl') }).optional().or(z.literal('')), + logo_url: z.string().url({ + message: t('globals.messages.invalid', { + name: t('globals.terms.url').toLowerCase() + }) + }).optional().or(z.literal('')), spacing: z.object({ side: z.number().min(0), bottom: z.number().min(0), @@ -39,7 +49,11 @@ export const createFormSchema = (t) => z.object({ trusted_domains: z.string().optional(), external_links: z.array(z.object({ text: z.string().min(1), - url: z.string().url({ message: t('globals.messages.invalidUrl') }) + url: z.string().url({ + message: t('globals.messages.invalid', { + name: t('globals.terms.url').toLowerCase() + }) + }) })), visitors: z.object({ start_conversation_button_text: z.string(), diff --git a/frontend/apps/main/src/features/conversation/message/AgentMessageBubble.vue b/frontend/apps/main/src/features/conversation/message/AgentMessageBubble.vue index d110a25c..5b656293 100644 --- a/frontend/apps/main/src/features/conversation/message/AgentMessageBubble.vue +++ b/frontend/apps/main/src/features/conversation/message/AgentMessageBubble.vue @@ -32,6 +32,9 @@ :class="{ 'mb-3': message.attachments.length > 0 }" /> + + + @@ -88,6 +91,7 @@ import { Spinner } from '@shared-ui/components/ui/spinner' import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar' import MessageAttachmentPreview from '@/features/conversation/message/attachment/MessageAttachmentPreview.vue' import MessageEnvelope from './MessageEnvelope.vue' +import CSATResponseDisplay from './CSATResponseDisplay.vue' import api from '../../../api' const props = defineProps({ diff --git a/frontend/apps/main/src/features/conversation/message/CSATResponseDisplay.vue b/frontend/apps/main/src/features/conversation/message/CSATResponseDisplay.vue new file mode 100644 index 00000000..e5467504 --- /dev/null +++ b/frontend/apps/main/src/features/conversation/message/CSATResponseDisplay.vue @@ -0,0 +1,64 @@ + + + + {{ $t('globals.terms.feedback') }}: + + + + + {{ getRatingEmoji(csatResponse.rating) }} + {{ getRatingText(csatResponse.rating) }} + ({{ csatResponse.rating }}/5) + + + + + "{{ csatResponse.feedback }}" + + + + + diff --git a/frontend/apps/main/src/features/conversation/message/MessageList.vue b/frontend/apps/main/src/features/conversation/message/MessageList.vue index bf628323..26951c25 100644 --- a/frontend/apps/main/src/features/conversation/message/MessageList.vue +++ b/frontend/apps/main/src/features/conversation/message/MessageList.vue @@ -43,33 +43,20 @@ + + + + + - - - - - - - {{ unReadMessages }} - - - + @@ -81,10 +68,12 @@ import AgentMessageBubble from './AgentMessageBubble.vue' import { useConversationStore } from '@main/stores/conversation' import { useUserStore } from '@main/stores/user' import { Button } from '@shared-ui/components/ui/button' -import { RefreshCw, ChevronDown } from 'lucide-vue-next' +import { RefreshCw } from 'lucide-vue-next' +import ScrollToBottomButton from '@shared-ui/components/ScrollToBottomButton' import { useEmitter } from '@main/composables/useEmitter' import { EMITTER_EVENTS } from '@main/constants/emitterEvents' import MessagesSkeleton from './MessagesSkeleton.vue' +import { TypingIndicator } from '@shared-ui/components/TypingIndicator' const conversationStore = useConversationStore() const userStore = useUserStore() @@ -108,7 +97,6 @@ const handleScroll = () => { } const handleScrollToBottom = () => { - unReadMessages.value = 0 scrollToBottom() } @@ -132,7 +120,11 @@ const handleNewMessage = () => { if (data.conversation_uuid === conversationStore.current.uuid) { if (data.message?.sender_id === userStore.userID) { scrollToBottom() - } else if (!isAtBottom.value) { + } else if (isAtBottom.value) { + // If user is at bottom, scroll to show new message + scrollToBottom() + } else { + // If user is not at bottom, increment unread counter but do not scroll unReadMessages.value++ } } @@ -156,6 +148,26 @@ watch( } ) +// Watch for typing indicator and auto-scroll if user is at bottom +watch( + () => conversationStore.conversation.isTyping, + (isTyping) => { + if (isTyping && isAtBottom.value) { + scrollToBottom() + } + } +) + +// Watch for isAtButtom and set unReadMessages to 0 +watch( + () => isAtBottom.value, + (atBottom) => { + if (atBottom) { + unReadMessages.value = 0 + } + } +) + const isPrivateNote = (message) => { return message.type === 'outgoing' && message.private } diff --git a/frontend/apps/main/src/stores/conversation.js b/frontend/apps/main/src/stores/conversation.js index 2465600c..b72c20cc 100644 --- a/frontend/apps/main/src/stores/conversation.js +++ b/frontend/apps/main/src/stores/conversation.js @@ -5,6 +5,7 @@ import { handleHTTPError } from '../utils/http' import { computeRecipientsFromMessage } from '../utils/email-recipients' import { useEmitter } from '../composables/useEmitter' import { EMITTER_EVENTS } from '../constants/emitterEvents' +import { subscribeToConversation, sendTypingIndicator } from '@main/websocket' import MessageCache from '../utils/conversation-message-cache' import api from '../api' @@ -101,7 +102,8 @@ export const useConversationStore = defineStore('conversation', () => { data: null, participants: {}, loading: false, - errorMessage: '' + errorMessage: '', + isTyping: false }) const messages = reactive({ @@ -242,6 +244,15 @@ export const useConversationStore = defineStore('conversation', () => { const conv = conversation.data const msgData = messages.data const inboxEmail = conv?.inbox_mail + + // If the conversation is a live chat, reset recipients. + if (conv?.inbox_channel === 'livechat') { + currentTo.value = [] + currentCC.value = [] + currentBCC.value = [] + return + } + if (!conv || !msgData || !inboxEmail) return const latestMessage = msgData.getLatestMessage(conv.uuid, ['incoming', 'outgoing'], true) @@ -284,6 +295,8 @@ export const useConversationStore = defineStore('conversation', () => { try { const resp = await api.getConversation(uuid) conversation.data = resp.data.data + // Do a websocket subscription to the conversation. + subscribeToConversation(uuid) } catch (error) { conversation.errorMessage = handleHTTPError(error).message emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { @@ -643,23 +656,12 @@ export const useConversationStore = defineStore('conversation', () => { } } - function resetCurrentConversation () { - Object.assign(conversation, { - data: null, - participants: {}, - macro: {}, - loading: false, - errorMessage: '' - }) - } - function resetConversations () { conversations.data = [] conversations.page = 1 seenConversationUUIDs = new Map() } - /** Macros set for new conversation or an open conversation **/ async function setMacro (macro, context) { macros.value[context] = macro @@ -678,6 +680,22 @@ export const useConversationStore = defineStore('conversation', () => { macros.value = { ...macros.value, [context]: {} } } + // Typing indicators + function updateTypingStatus (typingData) { + const { conversation_uuid, is_typing } = typingData + + // Only update typing status for the current conversation + if (conversation.data?.uuid !== conversation_uuid) return + + conversation.isTyping = is_typing + } + + function sendTyping (isTyping) { + if (conversation.data?.uuid) { + sendTypingIndicator(conversation.data.uuid, isTyping) + } + } + return { conversations, conversation, @@ -710,7 +728,6 @@ export const useConversationStore = defineStore('conversation', () => { updatePriority, updateStatus, updateConversationList, - resetCurrentConversation, fetchFirstPageConversations, fetchStatuses, fetchPriorities, @@ -727,6 +744,8 @@ export const useConversationStore = defineStore('conversation', () => { priorities, priorityOptions, statusOptionsNoSnooze, - statusOptions + statusOptions, + updateTypingStatus, + sendTyping } }) diff --git a/frontend/apps/main/src/utils/conversation-message-cache.js b/frontend/apps/main/src/utils/conversation-message-cache.js index 3ccf5adc..944559df 100644 --- a/frontend/apps/main/src/utils/conversation-message-cache.js +++ b/frontend/apps/main/src/utils/conversation-message-cache.js @@ -1,6 +1,8 @@ export default class MessageCache { /** * Cache for conversation messages with eviction of old conversations + * NOTE- This is not reactive, check implementation in `widget/store/chat.js` to see how this is made reactive. + * * @param {number} maxConvs - Max conversations to store before eviction */ constructor(maxConvs = 100) { @@ -129,6 +131,20 @@ export default class MessageCache { }) } + /** + * Removes a message from the cache + */ + removeMessage (convId, msgId) { + const conv = this.cache.get(convId) + if (!conv) return + conv.pages.forEach(msgs => { + const msgIndex = msgs.findIndex(m => m.uuid === msgId) + if (msgIndex !== -1) { + msgs.splice(msgIndex, 1) + } + }) + } + /** * Checks if conversation has more pages to fetch */ diff --git a/frontend/apps/main/src/websocket.js b/frontend/apps/main/src/websocket.js index 29aa0ebc..648d27bb 100644 --- a/frontend/apps/main/src/websocket.js +++ b/frontend/apps/main/src/websocket.js @@ -1,5 +1,5 @@ import { useConversationStore } from './stores/conversation' -import { WS_EVENT } from './constants/websocket' +import { WS_EVENT, WS_EPHEMERAL_TYPES } from './constants/websocket' export class WebSocketClient { constructor() { @@ -13,6 +13,10 @@ export class WebSocketClient { this.pingInterval = null this.lastPong = Date.now() this.convStore = useConversationStore() + this.messageQueue = [] + this.maxQueueSize = 50 + // 30 sec. + this.queueTimeoutMs = 30000 } init () { @@ -42,6 +46,8 @@ export class WebSocketClient { this.isReconnecting = false this.lastPong = Date.now() this.setupPing() + // Send any queued messages after connection is established. + this.flushMessageQueue() } handleMessage (event) { @@ -61,7 +67,13 @@ export class WebSocketClient { this.convStore.updateConversationMessage(data.data) }, [WS_EVENT.MESSAGE_PROP_UPDATE]: () => this.convStore.updateMessageProp(data.data), - [WS_EVENT.CONVERSATION_PROP_UPDATE]: () => this.convStore.updateConversationProp(data.data) + [WS_EVENT.CONVERSATION_PROP_UPDATE]: () => this.convStore.updateConversationProp(data.data), + [WS_EVENT.CONVERSATION_SUBSCRIBED]: () => { + console.log('Successfully subscribed to conversation:', data.data.conversation_uuid) + }, + [WS_EVENT.TYPING]: () => { + this.convStore.updateTypingStatus(data.data) + } } const handler = handlers[data.type] @@ -144,10 +156,91 @@ export class WebSocketClient { if (this.socket?.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)) } else { - console.warn('WebSocket is not open. Message not sent:', message) + console.warn('WebSocket is not open. Queueing message:', message) + this.queueMessage(message) } } + queueMessage (message) { + // Don't queue ephemeral message types. + if (WS_EPHEMERAL_TYPES.includes(message.type)) { + console.log('Skipping queue for ephemeral message type:', message.type) + return + } + + // Remove expired messages from queue. + const now = Date.now() + this.messageQueue = this.messageQueue.filter(item => + now - item.timestamp < this.queueTimeoutMs + ) + + // Remove all existing conversation subscriptions since only one is allowed. + if (message.type === WS_EVENT.CONVERSATION_SUBSCRIBE) { + this.messageQueue = this.messageQueue.filter(item => + item.type !== WS_EVENT.CONVERSATION_SUBSCRIBE + ) + } + + // Evict oldest message if queue is full. + if (this.messageQueue.length >= this.maxQueueSize) { + console.warn('Message queue is full, removing oldest message') + this.messageQueue.shift() + } + + // Push. + this.messageQueue.push({ + ...message, + timestamp: now + }) + } + + flushMessageQueue () { + if (this.messageQueue.length === 0) return + + // Remove expired messages before sending + const now = Date.now() + this.messageQueue = this.messageQueue.filter(item => + now - item.timestamp < this.queueTimeoutMs + ) + + if (this.messageQueue.length === 0) return + + console.log(`Sending ${this.messageQueue.length} queued messages`) + while (this.messageQueue.length > 0 && this.socket?.readyState === WebSocket.OPEN) { + const queuedItem = this.messageQueue.shift() + // Remove timestamp before sending + delete queuedItem.timestamp + this.socket.send(JSON.stringify(queuedItem)) + } + } + + subscribeToConversation (conversationUUID) { + if (!conversationUUID) return + + const subscribeMessage = { + type: WS_EVENT.CONVERSATION_SUBSCRIBE, + data: { + conversation_uuid: conversationUUID + } + } + + this.send(subscribeMessage) + } + + sendTypingIndicator (conversationUUID, isTyping) { + if (!conversationUUID) return + + const typingMessage = { + type: WS_EVENT.TYPING, + data: { + conversation_uuid: conversationUUID, + is_typing: isTyping + } + } + + this.send(typingMessage) + } + close () { this.manualClose = true this.clearPing() @@ -168,4 +261,6 @@ export function initWS () { } export const sendMessage = message => wsClient?.send(message) +export const subscribeToConversation = conversationUUID => wsClient?.subscribeToConversation(conversationUUID) +export const sendTypingIndicator = (conversationUUID, isTyping) => wsClient?.sendTypingIndicator(conversationUUID, isTyping) export const closeWebSocket = () => wsClient?.close() \ No newline at end of file diff --git a/frontend/apps/widget/src/App.vue b/frontend/apps/widget/src/App.vue index 15aad395..2d81a6f7 100644 --- a/frontend/apps/widget/src/App.vue +++ b/frontend/apps/widget/src/App.vue @@ -1,5 +1,5 @@ - + @@ -7,32 +7,75 @@