From 8ee81c2d640f8f0321b332ddacb7337dca658e55 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 17 Jul 2025 01:06:54 +0530 Subject: [PATCH] feat: Widget dark mode and chat reply expectation message in chat title. feat: Add HTTP utility functions for trusted origin checks feat: Implement typing status broadcasting for live chat clients and agents. feat: Add support for signed URLs in media manager fix: Update database migration to handle duplicate visitors with same email address. feat: Add conversation subscription and typing message models for WebSocket communication feat: Implement conversation subscription management in WebSocket hub this is used for broadcasting typing indicator. feat: Revamp widget JavaScript to improve mobile responsiveness and show unread messages if any. --- cmd/chat.go | 528 +++++---- cmd/conversation.go | 5 - cmd/csat.go | 44 +- cmd/handlers.go | 23 +- cmd/inboxes.go | 14 + cmd/init.go | 9 +- cmd/main.go | 4 + cmd/media.go | 60 +- cmd/messages.go | 11 +- cmd/middlewares.go | 17 + cmd/widget_middleware.go | 106 ++ cmd/widget_ws.go | 54 +- .../main/src/components/editor/TextEditor.vue | 15 + frontend/apps/main/src/constants/websocket.js | 10 +- .../admin/inbox/LivechatInboxForm.vue | 1043 +++++++++-------- .../admin/inbox/livechatFormSchema.js | 20 +- .../message/AgentMessageBubble.vue | 4 + .../message/CSATResponseDisplay.vue | 64 + .../conversation/message/MessageList.vue | 64 +- frontend/apps/main/src/stores/conversation.js | 47 +- .../src/utils/conversation-message-cache.js | 16 + frontend/apps/main/src/websocket.js | 101 +- frontend/apps/widget/src/App.vue | 75 +- frontend/apps/widget/src/api/index.js | 27 +- .../src/components/CSATMessageBubble.vue | 113 ++ .../apps/widget/src/components/ChatHeader.vue | 21 + .../widget/src/components/ChatMessages.vue | 247 ++++ .../apps/widget/src/components/ChatTitle.vue | 107 ++ .../src/components/CloseWidgetButton.vue | 28 + .../src/components/HomeExternalLinks.vue | 34 + .../apps/widget/src/components/HomeHeader.vue | 27 + .../src/components/MessageAttachment.vue | 2 +- .../widget/src/components/MessageInput.vue | 234 ++++ .../widget/src/components/MessagesList.vue | 89 ++ .../src/components/RecentConversationCard.vue | 69 ++ .../src/components/UnreadCountBadge.vue | 17 + .../widget/src/components/WidgetError.vue | 2 +- .../src/composables/useBusinessHours.js | 228 ++++ .../widget/src/composables/useRelativeTime.js | 13 + .../widget/src/composables/useUnreadCount.js | 40 + .../apps/widget/src/layouts/MainLayout.vue | 8 +- .../apps/widget/src/layouts/WidgetHeader.vue | 13 + frontend/apps/widget/src/main.js | 55 +- frontend/apps/widget/src/store/chat.js | 176 ++- frontend/apps/widget/src/store/user.js | 18 +- frontend/apps/widget/src/store/widget.js | 7 + frontend/apps/widget/src/views/ChatView.vue | 393 +------ frontend/apps/widget/src/views/HomeView.vue | 107 +- .../apps/widget/src/views/MessagesView.vue | 97 +- frontend/apps/widget/src/websocket.js | 91 +- .../ScrollToBottomButton.vue | 42 + .../components/ScrollToBottomButton/index.js | 1 + .../TypingIndicator/TypingIndicator.vue | 9 + .../components/TypingIndicator/index.js | 1 + frontend/shared-ui/components/index.js | 14 - frontend/shared-ui/composables/index.js | 1 + .../composables/useTypingIndicator.js | 46 + frontend/shared-ui/utils/datetime.js | 6 +- i18n/en.json | 13 +- internal/attachment/attachment.go | 7 + internal/business_hours/queries.sql | 5 +- internal/conversation/conversation.go | 34 + internal/conversation/message.go | 219 ++-- internal/conversation/models/models.go | 175 ++- internal/conversation/queries.sql | 37 +- internal/conversation/ws.go | 54 + internal/csat/csat.go | 3 +- internal/httputil/httputil.go | 90 ++ internal/inbox/channel/email/imap.go | 1 + internal/inbox/channel/livechat/livechat.go | 80 +- internal/media/media.go | 66 +- internal/media/models/models.go | 28 +- internal/migrations/v0.8.0.go | 49 +- internal/user/models/models.go | 13 +- internal/user/queries.sql | 15 +- internal/user/user.go | 6 +- internal/user/visitor.go | 8 +- internal/ws/client.go | 79 +- internal/ws/models/models.go | 15 + internal/ws/ws.go | 129 +- schema.sql | 8 +- static/widget.js | 159 ++- 82 files changed, 4281 insertions(+), 1729 deletions(-) create mode 100644 cmd/widget_middleware.go create mode 100644 frontend/apps/main/src/features/conversation/message/CSATResponseDisplay.vue create mode 100644 frontend/apps/widget/src/components/CSATMessageBubble.vue create mode 100644 frontend/apps/widget/src/components/ChatHeader.vue create mode 100644 frontend/apps/widget/src/components/ChatMessages.vue create mode 100644 frontend/apps/widget/src/components/ChatTitle.vue create mode 100644 frontend/apps/widget/src/components/CloseWidgetButton.vue create mode 100644 frontend/apps/widget/src/components/HomeExternalLinks.vue create mode 100644 frontend/apps/widget/src/components/HomeHeader.vue create mode 100644 frontend/apps/widget/src/components/MessageInput.vue create mode 100644 frontend/apps/widget/src/components/MessagesList.vue create mode 100644 frontend/apps/widget/src/components/RecentConversationCard.vue create mode 100644 frontend/apps/widget/src/components/UnreadCountBadge.vue create mode 100644 frontend/apps/widget/src/composables/useBusinessHours.js create mode 100644 frontend/apps/widget/src/composables/useRelativeTime.js create mode 100644 frontend/apps/widget/src/composables/useUnreadCount.js create mode 100644 frontend/apps/widget/src/layouts/WidgetHeader.vue create mode 100644 frontend/shared-ui/components/ScrollToBottomButton/ScrollToBottomButton.vue create mode 100644 frontend/shared-ui/components/ScrollToBottomButton/index.js create mode 100644 frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue create mode 100644 frontend/shared-ui/components/TypingIndicator/index.js delete mode 100644 frontend/shared-ui/components/index.js create mode 100644 frontend/shared-ui/composables/index.js create mode 100644 frontend/shared-ui/composables/useTypingIndicator.js create mode 100644 internal/httputil/httputil.go 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 @@ + + diff --git a/frontend/apps/widget/src/components/ChatHeader.vue b/frontend/apps/widget/src/components/ChatHeader.vue new file mode 100644 index 00000000..08da583c --- /dev/null +++ b/frontend/apps/widget/src/components/ChatHeader.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/apps/widget/src/components/ChatMessages.vue b/frontend/apps/widget/src/components/ChatMessages.vue new file mode 100644 index 00000000..0152975a --- /dev/null +++ b/frontend/apps/widget/src/components/ChatMessages.vue @@ -0,0 +1,247 @@ + + + diff --git a/frontend/apps/widget/src/components/ChatTitle.vue b/frontend/apps/widget/src/components/ChatTitle.vue new file mode 100644 index 00000000..8f5eebf9 --- /dev/null +++ b/frontend/apps/widget/src/components/ChatTitle.vue @@ -0,0 +1,107 @@ + + + diff --git a/frontend/apps/widget/src/components/CloseWidgetButton.vue b/frontend/apps/widget/src/components/CloseWidgetButton.vue new file mode 100644 index 00000000..0e7d888b --- /dev/null +++ b/frontend/apps/widget/src/components/CloseWidgetButton.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/apps/widget/src/components/HomeExternalLinks.vue b/frontend/apps/widget/src/components/HomeExternalLinks.vue new file mode 100644 index 00000000..dba02948 --- /dev/null +++ b/frontend/apps/widget/src/components/HomeExternalLinks.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/apps/widget/src/components/HomeHeader.vue b/frontend/apps/widget/src/components/HomeHeader.vue new file mode 100644 index 00000000..4197468c --- /dev/null +++ b/frontend/apps/widget/src/components/HomeHeader.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/apps/widget/src/components/MessageAttachment.vue b/frontend/apps/widget/src/components/MessageAttachment.vue index 711036c7..f915ec5e 100644 --- a/frontend/apps/widget/src/components/MessageAttachment.vue +++ b/frontend/apps/widget/src/components/MessageAttachment.vue @@ -1,5 +1,5 @@ + + diff --git a/frontend/apps/widget/src/components/MessagesList.vue b/frontend/apps/widget/src/components/MessagesList.vue new file mode 100644 index 00000000..573637f2 --- /dev/null +++ b/frontend/apps/widget/src/components/MessagesList.vue @@ -0,0 +1,89 @@ + + + diff --git a/frontend/apps/widget/src/components/RecentConversationCard.vue b/frontend/apps/widget/src/components/RecentConversationCard.vue new file mode 100644 index 00000000..77c5d488 --- /dev/null +++ b/frontend/apps/widget/src/components/RecentConversationCard.vue @@ -0,0 +1,69 @@ + + + diff --git a/frontend/apps/widget/src/components/UnreadCountBadge.vue b/frontend/apps/widget/src/components/UnreadCountBadge.vue new file mode 100644 index 00000000..edc2828e --- /dev/null +++ b/frontend/apps/widget/src/components/UnreadCountBadge.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/apps/widget/src/components/WidgetError.vue b/frontend/apps/widget/src/components/WidgetError.vue index d08085b8..a42259e4 100644 --- a/frontend/apps/widget/src/components/WidgetError.vue +++ b/frontend/apps/widget/src/components/WidgetError.vue @@ -1,5 +1,5 @@ diff --git a/frontend/apps/widget/src/composables/useBusinessHours.js b/frontend/apps/widget/src/composables/useBusinessHours.js new file mode 100644 index 00000000..94b30571 --- /dev/null +++ b/frontend/apps/widget/src/composables/useBusinessHours.js @@ -0,0 +1,228 @@ +import { format, isToday, isTomorrow, addDays, setHours, setMinutes } from 'date-fns' + +/** + * Business hours composable providing generic business hours utilities. + */ +export function useBusinessHours() { + + /** + * Get the business hours by ID from a list + * @param {number} businessHoursId - Business hours ID + * @param {Array} businessHoursList - List of business hours objects + * @returns {Object|null} Business hours object or null + */ + function getBusinessHoursById(businessHoursId, businessHoursList) { + if (!businessHoursId || !businessHoursList) { + return null + } + + return businessHoursList.find(bh => bh.id === businessHoursId) + } + + /** + * Determine which business hours to use based on configuration + * @param {Object} options - Configuration options + * @param {boolean} options.showOfficeHours - Whether to show office hours + * @param {boolean} options.showAfterAssignment - Whether to show team hours after assignment + * @param {number|null} options.assignedBusinessHoursId - Business hours ID from assignment + * @param {number|null} options.defaultBusinessHoursId - Default business hours ID + * @param {Array} options.businessHoursList - List of available business hours + * @returns {Object|null} Business hours object or null + */ + function resolveBusinessHours(options) { + const { + showOfficeHours, + showAfterAssignment, + assignedBusinessHoursId, + defaultBusinessHoursId, + businessHoursList + } = options + + if (!showOfficeHours) { + return null + } + + let businessHoursId = null + + // Check if we should use assigned business hours + if (showAfterAssignment && assignedBusinessHoursId) { + businessHoursId = assignedBusinessHoursId + } else if (defaultBusinessHoursId) { + // Fallback to default business hours + businessHoursId = parseInt(defaultBusinessHoursId) + } + + return getBusinessHoursById(businessHoursId, businessHoursList) + } + + /** + * Check if a given time is within business hours + * @param {Object} businessHours - Business hours object + * @param {Date} date - Date to check + * @param {number} utcOffset - UTC offset in minutes + * @returns {boolean} True if within business hours + */ + function isWithinBusinessHours(businessHours, date, utcOffset = 0) { + if (!businessHours || businessHours.is_always_open) { + return true + } + + // Convert to business timezone + const localDate = new Date(date.getTime() + (utcOffset * 60000)) + + // Check if it's a holiday + if (isHoliday(businessHours, localDate)) { + return false + } + + const dayName = getDayName(localDate.getDay()) + const schedule = businessHours.hours[dayName] + + if (!schedule || !schedule.open || !schedule.close) { + return false + } + + // Check if open and close times are the same (closed day) + if (schedule.open === schedule.close) { + return false + } + + const currentTime = format(localDate, 'HH:mm') + return currentTime >= schedule.open && currentTime <= schedule.close + } + + /** + * Check if a date is a holiday + * @param {Object} businessHours - Business hours object + * @param {Date} date - Date to check + * @returns {boolean} True if it's a holiday + */ + function isHoliday(businessHours, date) { + if (!businessHours.holidays || businessHours.holidays.length === 0) { + return false + } + const dateStr = format(date, 'yyyy-MM-dd') + return businessHours.holidays.some(holiday => holiday.date === dateStr) + } + + /** + * Get the next working time + * @param {Object} businessHours - Business hours object + * @param {Date} fromDate - Date to start from + * @param {number} utcOffset - UTC offset in minutes + * @returns {Date|null} Next working time or null + */ + function getNextWorkingTime(businessHours, fromDate, utcOffset = 0) { + if (!businessHours || businessHours.is_always_open) { + return fromDate + } + + // Check up to 14 days ahead + for (let i = 0; i < 14; i++) { + const checkDate = addDays(fromDate, i) + const localDate = new Date(checkDate.getTime() + (utcOffset * 60000)) + + // Skip holidays + if (isHoliday(businessHours, localDate)) { + continue + } + + const dayName = getDayName(localDate.getDay()) + const schedule = businessHours.hours[dayName] + + if (!schedule || !schedule.open || !schedule.close || schedule.open === schedule.close) { + continue + } + + // Parse opening time + const [openHour, openMinute] = schedule.open.split(':').map(Number) + let nextWorking = setMinutes(setHours(localDate, openHour), openMinute) + + // If it's the same day and current time is before opening time + if (i === 0) { + const currentTime = format(localDate, 'HH:mm') + if (currentTime < schedule.open) { + // Convert back from business timezone to user timezone + return new Date(nextWorking.getTime() - (utcOffset * 60000)) + } + // If it's the same day but past opening time, continue to next day + continue + } + + // For future days, return the opening time + // Convert back from business timezone to user timezone + return new Date(nextWorking.getTime() - (utcOffset * 60000)) + } + + return null + } + + /** + * Get day name from day number + * @param {number} dayNum - Day number (0 = Sunday, 1 = Monday, etc.) + * @returns {string} Day name + */ + function getDayName(dayNum) { + const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] + return days[dayNum] + } + + /** + * Format the next working time for display + * @param {Date} nextWorkingTime - Next working time + * @returns {string} Formatted string + */ + function formatNextWorkingTime(nextWorkingTime) { + if (!nextWorkingTime) { + return '' + } + + if (isToday(nextWorkingTime)) { + return `today at ${format(nextWorkingTime, 'h:mm a')}` + } else if (isTomorrow(nextWorkingTime)) { + return `tomorrow at ${format(nextWorkingTime, 'h:mm a')}` + } else { + return `on ${format(nextWorkingTime, 'EEEE')} at ${format(nextWorkingTime, 'h:mm a')}` + } + } + /** + * Get business hours status message and whether it's within business hours + * @param {Object} businessHours - Business hours object + * @param {number} utcOffset - UTC offset in minutes + * @param {string} withinHoursMessage - Message to show when within hours + * @returns {Object|null} { status: string|null, isWithin: boolean } or null + */ + function getBusinessHoursStatus(businessHours, utcOffset = 0, withinHoursMessage = '') { + if (!businessHours) { + return null + } + + const now = new Date() + const within = isWithinBusinessHours(businessHours, now, utcOffset) + + let status = null + if (within) { + status = withinHoursMessage + } else { + const nextWorkingTime = getNextWorkingTime(businessHours, now, utcOffset) + if (nextWorkingTime) { + status = `We'll be back ${formatNextWorkingTime(nextWorkingTime)}` + } else { + status = 'We are currently offline' + } + } + + return { status, isWithin: within } + } + + return { + getBusinessHoursById, + resolveBusinessHours, + isWithinBusinessHours, + getNextWorkingTime, + formatNextWorkingTime, + getBusinessHoursStatus, + isHoliday, + getDayName + } +} diff --git a/frontend/apps/widget/src/composables/useRelativeTime.js b/frontend/apps/widget/src/composables/useRelativeTime.js new file mode 100644 index 00000000..91a60b66 --- /dev/null +++ b/frontend/apps/widget/src/composables/useRelativeTime.js @@ -0,0 +1,13 @@ +import { ref } from 'vue' +import { useIntervalFn } from '@vueuse/core' +import { getRelativeTime } from '@shared-ui/utils/datetime.js' + +export function useRelativeTime (timestamp) { + const relativeTime = ref(getRelativeTime(timestamp)) + + useIntervalFn(() => { + relativeTime.value = getRelativeTime(timestamp) + }, 60000) + + return relativeTime +} \ No newline at end of file diff --git a/frontend/apps/widget/src/composables/useUnreadCount.js b/frontend/apps/widget/src/composables/useUnreadCount.js new file mode 100644 index 00000000..e1e2eba1 --- /dev/null +++ b/frontend/apps/widget/src/composables/useUnreadCount.js @@ -0,0 +1,40 @@ +import { computed, watch } from 'vue' +import { useChatStore } from '@widget/store/chat.js' + +export function useUnreadCount() { + const chatStore = useChatStore() + + // Calculate total unread messages across all conversations. + const totalUnreadCount = computed(() => { + const conversations = chatStore.getConversations + if (!conversations || conversations.length === 0) return 0 + + return conversations.reduce((total, conversation) => { + return total + (conversation.unread_message_count || 0) + }, 0) + }) + + // Send unread count to parent widget. + const sendUnreadCountToWidget = (count) => { + try { + if (window.parent && window.parent !== window) { + window.parent.postMessage({ + type: 'UPDATE_UNREAD_COUNT', + count: count + }, '*') + } + } catch (error) { + console.error('Failed to send unread count to widget:', error) + } + } + + // Watch for changes in unread count and notify the widget. + watch(totalUnreadCount, (newCount) => { + sendUnreadCountToWidget(newCount) + }, { immediate: true }) + + return { + totalUnreadCount, + sendUnreadCountToWidget + } +} diff --git a/frontend/apps/widget/src/layouts/MainLayout.vue b/frontend/apps/widget/src/layouts/MainLayout.vue index 4ca23df3..f6e1aadd 100644 --- a/frontend/apps/widget/src/layouts/MainLayout.vue +++ b/frontend/apps/widget/src/layouts/MainLayout.vue @@ -10,17 +10,17 @@ - - + + Home - + Messages -
+
Powered by Libredesk diff --git a/frontend/apps/widget/src/layouts/WidgetHeader.vue b/frontend/apps/widget/src/layouts/WidgetHeader.vue new file mode 100644 index 00000000..4cacc7dd --- /dev/null +++ b/frontend/apps/widget/src/layouts/WidgetHeader.vue @@ -0,0 +1,13 @@ + + diff --git a/frontend/apps/widget/src/main.js b/frontend/apps/widget/src/main.js index a951d834..2764b577 100644 --- a/frontend/apps/widget/src/main.js +++ b/frontend/apps/widget/src/main.js @@ -1,10 +1,57 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' +import { createI18n } from 'vue-i18n' import App from './App.vue' +import api from './api/index.js' import '@shared-ui/assets/styles/main.scss' import './assets/widget.css' -const app = createApp(App) -const pinia = createPinia() -app.use(pinia) -app.mount('#app') +async function initWidget () { + try { + // Get `inbox_id` from URL params + const urlParams = new URLSearchParams(window.location.search) + const inboxID = urlParams.get('inbox_id') + + if (!inboxID) { + throw new Error('`inbox_id` is missing in query parameters') + } + + // Fetch widget settings to get language config + const widgetSettingsResponse = await api.getWidgetSettings(inboxID) + const widgetConfig = widgetSettingsResponse.data.data + + // Get language from config or default to 'en' + const lang = widgetConfig.language || 'en' + + // Fetch language messages + const langMessages = await api.getLanguage(lang) + + // Initialize i18n + const i18nConfig = { + legacy: false, + locale: lang, + fallbackLocale: 'en', + messages: { + [lang]: langMessages.data + } + } + + const i18n = createI18n(i18nConfig) + const app = createApp(App) + const pinia = createPinia() + + app.use(pinia) + app.use(i18n) + // Store widget config globally for access in App.vue + app.config.globalProperties.$widgetConfig = widgetConfig + app.mount('#app') + } catch (error) { + console.error('Error initializing widget:', error) + const app = createApp(App) + const pinia = createPinia() + app.use(pinia) + app.mount('#app') + } +} + +initWidget() diff --git a/frontend/apps/widget/src/store/chat.js b/frontend/apps/widget/src/store/chat.js index 12dc8448..f04ec9ea 100644 --- a/frontend/apps/widget/src/store/chat.js +++ b/frontend/apps/widget/src/store/chat.js @@ -1,6 +1,5 @@ import { defineStore } from 'pinia' import { ref, computed, reactive } from 'vue' -import { initWidgetWS } from '../websocket.js' import api from '../api/index.js' import MessageCache from '@main/utils/conversation-message-cache.js' import { useUserStore } from './user.js' @@ -14,75 +13,121 @@ export const useChatStore = defineStore('chat', () => { // Conversation messages cache, evict old conversation messages after 50 conversations. const messageCache = reactive(new MessageCache(50)) const isLoadingConversations = ref(false) + // Reactivity trigger for message cache changes this is easier than making the whole messageCache reactive. + const messageCacheVersion = ref(0) // Getters - const getCurrentConversationMessages = () => { + const getCurrentConversationMessages = computed(() => { + messageCacheVersion.value // Force reactivity tracking const convId = currentConversation.value?.uuid if (!convId) return [] return messageCache.getAllPagesMessages(convId) - } + }) const hasConversations = computed(() => conversations.value?.length > 0) const getConversations = computed(() => { - // Sort by `last_message_at` descending. + // Sort by `last_message.created_at` descending. if (conversations.value) { - return conversations.value.sort((a, b) => new Date(b.last_message_at) - new Date(a.last_message_at)) + return conversations.value.sort((a, b) => new Date(b.last_message.created_at) - new Date(a.last_message.created_at)) } return [] }) - // Actions + const updateConversationListLastMessage = (conversationUUID, message, incrementUnread = false) => { + if (!conversations.value || !Array.isArray(conversations.value)) return + + // Find conversation in the list + const conv = conversations.value.find(c => c.uuid === conversationUUID) + if (!conv) return + + // Update last_message in the conversation + conv.last_message = { + content: message.text_content !== '' ? message.text_content : message.content, + created_at: message.created_at, + status: message.status, + author: { + id: message.author.id, + first_name: message.author.first_name || '', + last_name: message.author.last_name || '', + avatar_url: message.author.avatar_url || '', + availability_status: message.author.availability_status || '', + type: message.author.type || '', + active_at: message.author.active_at || null + } + } + + // Increment unread count if needed + if (incrementUnread) { + conv.unread_message_count = (conv.unread_message_count || 0) + 1 + } + } + const addMessageToConversation = (conversationUUID, message) => { messageCache.addMessage(conversationUUID, message) - // Update `last_message` for the conversations list. - const conv = conversations.value.find(c => c.uuid === conversationUUID) - if (conv) { - conv.last_message = message.text_content - conv.last_message_at = message.created_at - } + messageCacheVersion.value++ // Trigger reactivity + // Check if we should increment unread count (message from other user) + const shouldIncrementUnread = message.author.id !== userStore.userID + updateConversationListLastMessage(conversationUUID, message, shouldIncrementUnread) } - const fetchCurrentConversation = async () => { - const conversationUUID = currentConversation.value?.uuid - if (!conversationUUID) return - - // Set unread message count to 0 for the current conversation - const conv = conversations.value.find(c => c.uuid === conversationUUID) - if (conv) { - conv.unread_message_count = 0 + const addPendingMessage = (conversationUUID, messageText, authorType, authorId) => { + // Pending message is a temporary message that will be replaced with actual message later after sending. + const pendingMessage = { + content: messageText, + author: { + type: authorType, + id: authorId, + first_name: userStore.firstName || '', + last_name: userStore.lastName || '', + avatar_url: userStore.avatarUrl || '', + availability_status: '', + active_at: null + }, + attachments: [], + uuid: `pending-${Date.now()}`, + status: 'sending', + created_at: new Date().toISOString() } + messageCache.addMessage(conversationUUID, pendingMessage) + messageCacheVersion.value++ // Trigger reactivity - // If messages are already loaded, do nothing. - if (messageCache.hasConversation(conversationUUID)) { - return - } + // Update conversations list with pending message + updateConversationListLastMessage(conversationUUID, pendingMessage) - // Fetch entire conversation and replace messages and conversation data - try { - const resp = await api.getChatConversation(conversationUUID) - replaceMessages(resp.data.data.messages) - currentConversation.value = resp.data.data.conversation - } catch (error) { - console.error('Error fetching conversation:', error) - } + return pendingMessage.uuid } - const fetchAndReplaceConversationAndMessages = async () => { - const conversationUUID = currentConversation.value?.uuid - if (!conversationUUID) return - // Fetch entire conversation and replace messages + const replaceMessage = (conversationUUID, msgID, actualMessage) => { + messageCache.updateMessage(conversationUUID, msgID, actualMessage) + messageCacheVersion.value++ // Trigger reactivity + updateConversationListLastMessage(conversationUUID, actualMessage) + } + + const removeMessage = (conversationUUID, msgID) => { + messageCache.removeMessage(conversationUUID, msgID) + messageCacheVersion.value++ // Trigger reactivity + } + + const loadConversation = async (conversationUUID, force = false) => { + if (!conversationUUID) return false + + // If the conversation is already loaded, do not fetch again unless forced. + if (currentConversation.value?.uuid === conversationUUID && !force) { + return true + } + try { const resp = await api.getChatConversation(conversationUUID) + setCurrentConversation(resp.data.data.conversation) replaceMessages(resp.data.data.messages) currentConversation.value = resp.data.data.conversation - // Set last message for the conversation - const conv = conversations.value.find(c => c.uuid === conversationUUID) - if (conv) { - conv.last_message = resp.data.data.messages[0]?.content || '' - conv.last_message_at = resp.data.data.messages[0]?.created_at || new Date().toISOString() + if (resp.data.data.messages.length > 0) { + updateConversationListLastMessage(conversationUUID, resp.data.data.messages[0], false) } } catch (error) { console.error('Error fetching conversation:', error) + return false } + return true } const replaceMessages = (newMessages) => { @@ -93,16 +138,22 @@ export const useChatStore = defineStore('chat', () => { messageCache.purgeConversation(convId) messageCache.addMessages(convId, newMessages, 1, 1) } + messageCacheVersion.value++ // Trigger reactivity } const clearMessages = () => { const convId = currentConversation.value?.uuid if (!convId) return - // Clear messages for current conversation by setting empty array. + // Clear messages for current conversation by setting empty values. messageCache.addMessages(convId, [], 1, 1) + messageCacheVersion.value++ // Trigger reactivity } - const setTypingStatus = (status) => { + const setTypingStatus = (conversationUUID, status) => { + if (!conversationUUID) return + if (currentConversation.value?.uuid !== conversationUUID) { + return + } isTyping.value = status } @@ -110,27 +161,22 @@ export const useChatStore = defineStore('chat', () => { if (conversation === null) { conversation = {} } + // Clear messages if conversation is null or empty. + if (!conversation) { + clearMessages() + } currentConversation.value = conversation } - const openConversation = (conversation) => { - // Set the current conversation - setCurrentConversation(conversation) - - // Init WebSocket connection if not already initialized. - const jwt = userStore.userSessionToken - if (jwt) { - initWidgetWS(jwt) - } - } - const fetchConversations = async () => { + // No session token means no conversations can be fetched simply return empty. if (!userStore.userSessionToken) { conversations.value = [] return } - if (conversations.value !== null) { + // If conversations are already loaded and is an array, do not fetch again. + if (Array.isArray(conversations.value)) { return } @@ -154,7 +200,15 @@ export const useChatStore = defineStore('chat', () => { const updateCurrentConversationLastSeen = async () => { const conversationUUID = currentConversation.value?.uuid if (!conversationUUID) return - api.updateConversationLastSeen(conversationUUID) + + // Reset unread count for current conversation + if (conversations.value && Array.isArray(conversations.value)) { + const conv = conversations.value.find(c => c.uuid === conversationUUID) + if (conv) { + conv.unread_message_count = 0 + } + } + await api.updateConversationLastSeen(conversationUUID) } return { @@ -172,14 +226,16 @@ export const useChatStore = defineStore('chat', () => { // Actions addMessageToConversation, - fetchCurrentConversation, + addPendingMessage, + replaceMessage, + removeMessage, replaceMessages, clearMessages, setTypingStatus, setCurrentConversation, - openConversation, fetchConversations, - fetchAndReplaceConversationAndMessages, - updateCurrentConversationLastSeen + loadConversation, + updateCurrentConversationLastSeen, + updateConversationListLastMessage } }) diff --git a/frontend/apps/widget/src/store/user.js b/frontend/apps/widget/src/store/user.js index 18268a26..841ad780 100644 --- a/frontend/apps/widget/src/store/user.js +++ b/frontend/apps/widget/src/store/user.js @@ -14,13 +14,29 @@ export const useUserStore = defineStore('user', () => { return jwt.is_visitor }) + const userID = computed(() => { + const token = userSessionToken.value + if (!token) return null + const jwt = parseJWT(token) + return jwt.user_id || null + }) + const clearSessionToken = () => { userSessionToken.value = "" } + const setSessionToken = (token) => { + if (typeof token !== 'string') { + throw new Error('Session token must be a string') + } + userSessionToken.value = token + } + return { userSessionToken, isVisitor, - clearSessionToken + userID, + clearSessionToken, + setSessionToken } }) \ No newline at end of file diff --git a/frontend/apps/widget/src/store/widget.js b/frontend/apps/widget/src/store/widget.js index ec595e86..973f5946 100644 --- a/frontend/apps/widget/src/store/widget.js +++ b/frontend/apps/widget/src/store/widget.js @@ -7,6 +7,7 @@ export const useWidgetStore = defineStore('widget', () => { const currentView = ref('home') const config = ref({}) const isInChatView = ref(false) + const isMobileFullScreen = ref(false) // Getters @@ -49,12 +50,17 @@ export const useWidgetStore = defineStore('widget', () => { config.value = { ...newConfig } } + const setMobileFullScreen = (isMobile) => { + isMobileFullScreen.value = isMobile + } + return { // State isOpen, currentView, config, isInChatView, + isMobileFullScreen, // Getters isHomeView, @@ -69,5 +75,6 @@ export const useWidgetStore = defineStore('widget', () => { navigateToMessages, navigateToHome, updateConfig, + setMobileFullScreen, } }) diff --git a/frontend/apps/widget/src/views/ChatView.vue b/frontend/apps/widget/src/views/ChatView.vue index e8152a92..7c28afeb 100644 --- a/frontend/apps/widget/src/views/ChatView.vue +++ b/frontend/apps/widget/src/views/ChatView.vue @@ -1,402 +1,35 @@ diff --git a/frontend/apps/widget/src/views/HomeView.vue b/frontend/apps/widget/src/views/HomeView.vue index 97a2449d..a70c69d8 100644 --- a/frontend/apps/widget/src/views/HomeView.vue +++ b/frontend/apps/widget/src/views/HomeView.vue @@ -1,97 +1,74 @@ diff --git a/frontend/apps/widget/src/views/MessagesView.vue b/frontend/apps/widget/src/views/MessagesView.vue index b5b9a364..9cdf7b17 100644 --- a/frontend/apps/widget/src/views/MessagesView.vue +++ b/frontend/apps/widget/src/views/MessagesView.vue @@ -1,63 +1,14 @@ diff --git a/frontend/apps/widget/src/websocket.js b/frontend/apps/widget/src/websocket.js index a774f0de..af2aba78 100644 --- a/frontend/apps/widget/src/websocket.js +++ b/frontend/apps/widget/src/websocket.js @@ -1,6 +1,6 @@ -import { useChatStore } from './store/chat' - // Widget WebSocket message types (matching backend constants) +import { useChatStore } from './store/chat.js' + export const WS_EVENT = { JOIN: 'join', MESSAGE: 'message', @@ -23,13 +23,14 @@ export class WidgetWebSocketClient { this.manualClose = false this.pingInterval = null this.lastPong = Date.now() - this.chatStore = useChatStore() this.jwt = null - this.isJoined = false + this.inboxId = null } - init (jwt) { + init (jwt, inboxId) { + this.manualClose = false this.jwt = jwt + this.inboxId = inboxId this.connect() this.setupNetworkListeners() } @@ -50,44 +51,48 @@ export class WidgetWebSocketClient { } handleOpen () { - console.log('Widget WebSocket connected') this.reconnectInterval = 1000 + const wasReconnecting = this.reconnectAttempts > 0 this.reconnectAttempts = 0 this.isReconnecting = false this.lastPong = Date.now() this.setupPing() - // Auto-join conversation after connection if a conversation uuid is set. - if (this.chatStore.currentConversation.uuid && this.jwt && !this.isJoined) { - this.joinConversation() + // Auto-join inbox after connection if inbox_id is set. + if (this.inboxId && this.jwt) { + this.joinInbox() + } + + // If this was a reconnection, sync current conversation messages + if (wasReconnecting) { + this.resyncCurrentConversation() } } handleMessage (event) { + const chatStore = useChatStore() try { if (!event.data) return const data = JSON.parse(event.data) const handlers = { [WS_EVENT.JOINED]: () => { - this.isJoined = true + // Joined inbox. }, [WS_EVENT.PONG]: () => { this.lastPong = Date.now() }, [WS_EVENT.NEW_MESSAGE]: () => { - // Add new message to chat store if (data.data) { - this.chatStore.addMessageToConversation(data.data.conversation_uuid, data.data) + chatStore.addMessageToConversation(data.data.conversation_uuid, data.data) } }, [WS_EVENT.ERROR]: () => { console.error('Widget WebSocket error:', data.data) }, [WS_EVENT.TYPING]: () => { - // TODO: check conversation uuid and then set typing as true. - // if (data.data && data.data.is_typing !== undefined) { - // this.chatStore.setTypingStatus(data.data.is_typing) - // } + if (data.data && data.data.is_typing !== undefined) { + chatStore.setTypingStatus(data.data.conversation_uuid, data.data.is_typing) + } } } const handler = handlers[data.type] @@ -108,7 +113,6 @@ export class WidgetWebSocketClient { handleClose () { this.clearPing() - this.isJoined = false if (!this.manualClose) { this.reconnect() } @@ -169,10 +173,9 @@ export class WidgetWebSocketClient { } } - joinConversation () { - const currentConversationUuid = this.chatStore.currentConversation.uuid - if (!currentConversationUuid || !this.jwt) { - console.error('Cannot join conversation: missing conversationUuid or JWT') + joinInbox () { + if (!this.inboxId || !this.jwt) { + console.error('Cannot join inbox: missing inbox_id or JWT') return } @@ -180,29 +183,31 @@ export class WidgetWebSocketClient { type: WS_EVENT.JOIN, jwt: this.jwt, data: { - conversation_uuid: currentConversationUuid + inbox_id: parseInt(this.inboxId, 10) } } this.send(joinMessage) } - sendTyping (isTyping = true) { - if (!this.isJoined) { - console.warn('Cannot send typing indicator: not joined to conversation') - return + // Resync current conversation after reconnection to catch any missed messages. + resyncCurrentConversation () { + const chatStore = useChatStore() + const currentConversationUUID = chatStore.currentConversation?.uuid + if (currentConversationUUID) { + chatStore.loadConversation(currentConversationUUID) } + } - const currentConversationUUID = this.chatStore.currentConversation.uuid + sendTyping (isTyping = true, conversationUUID = null) { const typingMessage = { type: WS_EVENT.TYPING, jwt: this.jwt, data: { - conversation_uuid: currentConversationUUID, + conversation_uuid: conversationUUID, is_typing: isTyping } } - this.send(typingMessage) } @@ -214,19 +219,8 @@ export class WidgetWebSocketClient { } } - // Method to join a new conversation without reinitializing the connection - joinNewConversation () { - if (this.socket?.readyState === WebSocket.OPEN) { - this.isJoined = false - this.joinConversation() - } else { - console.warn('WebSocket not connected, cannot join new conversation') - } - } - close () { this.manualClose = true - this.isJoined = false this.clearPing() if (this.socket) { this.socket.close() @@ -236,26 +230,25 @@ export class WidgetWebSocketClient { let widgetWSClient -export function initWidgetWS (jwt) { +export function initWidgetWS (jwt, inboxId) { if (!widgetWSClient) { widgetWSClient = new WidgetWebSocketClient() - widgetWSClient.init(jwt) + widgetWSClient.init(jwt, inboxId) } else { - // Update JWT and rejoin if connection exists + // Update JWT and inbox_id and rejoin if connection exists widgetWSClient.jwt = jwt + widgetWSClient.inboxId = inboxId if (widgetWSClient.socket?.readyState === WebSocket.OPEN) { - // Reset joined status and join the new conversation - widgetWSClient.isJoined = false - widgetWSClient.joinConversation() + widgetWSClient.joinInbox() } else { // If connection is not open, reconnect - widgetWSClient.init(jwt) + widgetWSClient.init(jwt, inboxId) } } return widgetWSClient } export const sendWidgetMessage = message => widgetWSClient?.send(message) -export const sendWidgetTyping = (isTyping = true) => widgetWSClient?.sendTyping(isTyping) +export const sendWidgetTyping = (isTyping = true, conversationUUID = null) => widgetWSClient?.sendTyping(isTyping, conversationUUID) export const closeWidgetWebSocket = () => widgetWSClient?.close() -export const joinNewConversation = () => widgetWSClient?.joinNewConversation() +export const reOpenWidgetWebSocket = () => widgetWSClient?.reOpen() diff --git a/frontend/shared-ui/components/ScrollToBottomButton/ScrollToBottomButton.vue b/frontend/shared-ui/components/ScrollToBottomButton/ScrollToBottomButton.vue new file mode 100644 index 00000000..c8427832 --- /dev/null +++ b/frontend/shared-ui/components/ScrollToBottomButton/ScrollToBottomButton.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/shared-ui/components/ScrollToBottomButton/index.js b/frontend/shared-ui/components/ScrollToBottomButton/index.js new file mode 100644 index 00000000..bab1c7b6 --- /dev/null +++ b/frontend/shared-ui/components/ScrollToBottomButton/index.js @@ -0,0 +1 @@ +export { default } from './ScrollToBottomButton.vue' diff --git a/frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue b/frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue new file mode 100644 index 00000000..9b49915b --- /dev/null +++ b/frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue @@ -0,0 +1,9 @@ + diff --git a/frontend/shared-ui/components/TypingIndicator/index.js b/frontend/shared-ui/components/TypingIndicator/index.js new file mode 100644 index 00000000..7b2c578f --- /dev/null +++ b/frontend/shared-ui/components/TypingIndicator/index.js @@ -0,0 +1 @@ +export { default as TypingIndicator } from './TypingIndicator.vue' diff --git a/frontend/shared-ui/components/index.js b/frontend/shared-ui/components/index.js deleted file mode 100644 index a05b21db..00000000 --- a/frontend/shared-ui/components/index.js +++ /dev/null @@ -1,14 +0,0 @@ -// Button component exports -export { Button, buttonVariants } from './ui/button' - -// Card component exports -export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './ui/card' - -// Input component exports -export { Input } from './ui/input' - -// Add other component exports as needed -// Example: -// export { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog' -// export { Badge, badgeVariants } from './ui/badge' -// export { Avatar, AvatarImage, AvatarFallback } from './ui/avatar' diff --git a/frontend/shared-ui/composables/index.js b/frontend/shared-ui/composables/index.js new file mode 100644 index 00000000..5c9b4961 --- /dev/null +++ b/frontend/shared-ui/composables/index.js @@ -0,0 +1 @@ +export { useTypingIndicator } from './useTypingIndicator.js' diff --git a/frontend/shared-ui/composables/useTypingIndicator.js b/frontend/shared-ui/composables/useTypingIndicator.js new file mode 100644 index 00000000..810a2734 --- /dev/null +++ b/frontend/shared-ui/composables/useTypingIndicator.js @@ -0,0 +1,46 @@ +import { ref, onUnmounted } from 'vue' + +export function useTypingIndicator(sendTypingCallback) { + const typingTimer = ref(null) + const isCurrentlyTyping = ref(false) + + const startTyping = () => { + if (!isCurrentlyTyping.value) { + isCurrentlyTyping.value = true + sendTypingCallback?.(true) + } + + // Clear existing timer + if (typingTimer.value) { + clearTimeout(typingTimer.value) + } + + // Set timer to stop typing after 2 seconds of inactivity + typingTimer.value = setTimeout(() => { + stopTyping() + }, 2000) + } + + const stopTyping = () => { + if (isCurrentlyTyping.value) { + isCurrentlyTyping.value = false + sendTypingCallback?.(false) + } + + if (typingTimer.value) { + clearTimeout(typingTimer.value) + typingTimer.value = null + } + } + + // Clean up on unmount + onUnmounted(() => { + stopTyping() + }) + + return { + startTyping, + stopTyping, + isCurrentlyTyping + } +} diff --git a/frontend/shared-ui/utils/datetime.js b/frontend/shared-ui/utils/datetime.js index 657e83db..7b59893f 100644 --- a/frontend/shared-ui/utils/datetime.js +++ b/frontend/shared-ui/utils/datetime.js @@ -7,9 +7,9 @@ export function getRelativeTime (timestamp, now = new Date()) { const days = differenceInDays(now, timestamp) if (mins === 0) return 'Just now' - if (mins < 60) return `${mins} mins ago` - if (hours < 24) return `${hours} hrs ago` - if (days < 7) return `${days} days ago` + if (mins < 60) return `${mins}m ago` + if (hours < 24) return `${hours}h ago` + if (days < 7) return `${days}d ago` return format(timestamp, 'MMMM d, yyyy h:mm a') } catch (error) { console.error('Error parsing time', error, 'timestamp', timestamp) diff --git a/i18n/en.json b/i18n/en.json index d7acd44b..95e4b020 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -38,6 +38,7 @@ "globals.terms.dashboard": "Dashboard | Dashboards", "globals.terms.tag": "Tag | Tags", "globals.terms.sla": "SLA | SLAs", + "globals.terms.feedback": "Feedback | Feedbacks", "globals.terms.slaPolicy": "SLA Policy | SLA Policies", "globals.terms.csatSurvey": "CSAT Survey | CSAT Surveys", "globals.terms.csatResponse": "CSAT Response | CSAT Responses", @@ -264,8 +265,6 @@ "globals.messages.type": "{name} type", "globals.messages.typeOf": "Type of {name}", "globals.messages.invalidEmailAddress": "Invalid email address", - "globals.messages.invalidColor": "Invalid color format", - "globals.messages.invalidUrl": "Invalid URL format", "globals.messages.selectAtLeastOne": "Please select at least one {name}", "globals.messages.strongPassword": "Password must be between {min} and {max} characters long, should contain at least one uppercase letter, one lowercase letter, one number, and one special character.", "globals.messages.couldNotReload": "Could not reload {name}", @@ -276,6 +275,7 @@ "globals.messages.notFound": "{name} not found", "globals.messages.empty": "{name} empty", "globals.messages.mismatch": "{name} mismatch", + "globals.messages.notAllowed": "{name} not allowed", "globals.messages.errorSendingPasswordResetEmail": "Error sending password reset email", "globals.messages.cannotBeEmpty": "{name} cannot be empty", "globals.messages.pressEnterToSelectAValue": "Press enter to select a value", @@ -470,6 +470,12 @@ "admin.inbox.configureChannel": "Configure channel", "admin.inbox.createEmailInbox": "Create Email Inbox", "admin.inbox.livechatConfig": "Live Chat Configuration", + "admin.inbox.livechat.tabs.general": "General", + "admin.inbox.livechat.tabs.appearance": "Appearance", + "admin.inbox.livechat.tabs.messages": "Messages", + "admin.inbox.livechat.tabs.features": "Features", + "admin.inbox.livechat.tabs.security": "Security", + "admin.inbox.livechat.tabs.users": "Users", "admin.inbox.livechat.logoUrl": "Logo URL", "admin.inbox.livechat.logoUrl.description": "URL of the logo to display in the chat widget", "admin.inbox.livechat.secretKey": "Secret Key", @@ -488,6 +494,8 @@ "admin.inbox.livechat.introductionMessage": "Introduction Message", "admin.inbox.livechat.chatIntroduction": "Chat Introduction", "admin.inbox.livechat.chatIntroduction.description": "Default: Ask us anything, or share your feedback.", + "admin.inbox.livechat.chatReplyExpectationMessage": "Chat reply expectation message", + "admin.inbox.livechat.chatReplyExpectationMessage.description": "Message shown to customers during business hours about expected reply times", "admin.inbox.livechat.officeHours": "Office Hours", "admin.inbox.livechat.showOfficeHoursInChat": "Show Office Hours in Chat", "admin.inbox.livechat.showOfficeHoursInChat.description": "Show when the team will be next available", @@ -646,7 +654,6 @@ "account.removeAvatar": "Remove avatar", "account.cropAvatar": "Crop avatar", "account.avatarRemoved": "Avatar removed", - "conversation.resolveWithoutAssignee": "Cannot resolve the conversation without an assigned user, Please assign a user before attempting to resolve", "conversation.notMemberOfTeam": "You're not a member of this team, Please refresh the page and try again", "conversation.viewPermissionDenied": "You do not have access to this view", "conversation.errorGeneratingMessageID": "Error generating message ID", diff --git a/internal/attachment/attachment.go b/internal/attachment/attachment.go index a8f2c1d5..e73e88b1 100644 --- a/internal/attachment/attachment.go +++ b/internal/attachment/attachment.go @@ -38,6 +38,13 @@ func (a *Attachments) Scan(value interface{}) error { return json.Unmarshal(bytes, a) } +func (a Attachments) MarshalJSON() ([]byte, error) { + if a == nil { + a = make(Attachments, 0) + } + return json.Marshal([]Attachment(a)) +} + // MakeHeader creates a MIME header for email attachments or inline content. func MakeHeader(contentType, contentID, fileName, encoding, disposition string) textproto.MIMEHeader { if encoding == "" { diff --git a/internal/business_hours/queries.sql b/internal/business_hours/queries.sql index bf454951..e7872149 100644 --- a/internal/business_hours/queries.sql +++ b/internal/business_hours/queries.sql @@ -15,7 +15,10 @@ SELECT id, created_at, updated_at, "name", - description + description, + is_always_open, + hours, + holidays FROM business_hours ORDER BY updated_at DESC; diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index f3599057..3c4c0174 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -111,6 +111,7 @@ type userStore interface { type mediaStore interface { GetBlob(name string) ([]byte, error) + GetURL(name string) string Attach(id int, model string, modelID int) error GetByModel(id int, model string) ([]mmodels.Media, error) ContentIDExists(contentID string) (bool, string, error) @@ -129,6 +130,7 @@ type settingsStore interface { type csatStore interface { Create(conversationID int) (csatModels.CSATResponse, error) + Get(uuid string) (csatModels.CSATResponse, error) MakePublicURL(appBaseURL, uuid string) string } @@ -1147,3 +1149,35 @@ func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listType "conversation_statuses": conversationStatusAllowedFields, }) } + +// ProcessCSATStatus processes messages and adds CSAT submission status for CSAT messages. +func (m *Manager) ProcessCSATStatus(messages []models.Message) { + for i := range messages { + msg := &messages[i] + if msg.HasCSAT() { + // Extract CSAT UUID from message content + csatUUID := msg.ExtractCSATUUID() + if csatUUID == "" { + // Fallback to basic censoring if UUID extraction fails + msg.CensorCSATContent() + continue + } + + // Get CSAT submission status + csat, err := m.csatStore.Get(csatUUID) + isSubmitted := false + rating := 0 + feedback := "" + if err == nil && csat.ResponseTimestamp.Valid { + isSubmitted = true + rating = csat.Score + if csat.Feedback.Valid { + feedback = csat.Feedback.String + } + } + + // Censor content and add submission status + msg.CensorCSATContentWithStatus(isSubmitted, csatUUID, rating, feedback) + } + } +} diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 1418251b..f1658a68 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -18,6 +18,7 @@ import ( "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/image" "github.com/abhinavxd/libredesk/internal/inbox" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" mmodels "github.com/abhinavxd/libredesk/internal/media/models" "github.com/abhinavxd/libredesk/internal/sla" "github.com/abhinavxd/libredesk/internal/stringutil" @@ -102,7 +103,7 @@ func (m *Manager) IncomingMessageWorker(ctx context.Context) { if !ok { return } - if err := m.processIncomingMessage(msg); err != nil { + if _, err := m.ProcessIncomingMessage(msg); err != nil { m.lo.Error("error processing incoming msg", "error", err) } } @@ -180,11 +181,12 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { // Send message err = inb.Send(message) - if handleError(err, "error sending message") { + if err != nil && err != livechat.ErrClientNotConnected { + handleError(err, "error sending message") return } - // Update status. + // Update status as sent. m.UpdateMessageStatus(message.UUID, models.MessageStatusSent) // Skip system user replies since we only update timestamps and SLA for human replies. @@ -393,20 +395,22 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID, contactID return envelope.NewError(envelope.InputError, m.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil) } - // Save to, cc and bcc in meta. - to = stringutil.RemoveEmpty(to) - cc = stringutil.RemoveEmpty(cc) - bcc = stringutil.RemoveEmpty(bcc) - metaMap["to"] = to - if len(cc) > 0 { - metaMap["cc"] = cc - } - if len(bcc) > 0 { - metaMap["bcc"] = bcc - } - var sourceID = "" - if inboxRecord.Channel == "email" { + switch inboxRecord.Channel { + case inbox.ChannelEmail: + // Add `to`, `cc`, and `bcc` recipients to meta map. + to = stringutil.RemoveEmpty(to) + cc = stringutil.RemoveEmpty(cc) + bcc = stringutil.RemoveEmpty(bcc) + if len(to) > 0 { + metaMap["to"] = to + } + if len(cc) > 0 { + metaMap["cc"] = cc + } + if len(bcc) > 0 { + metaMap["bcc"] = bcc + } if len(to) == 0 { return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.empty", "name", "`to`"), nil) } @@ -415,7 +419,7 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID, contactID m.lo.Error("error generating source message id", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.T("conversation.errorGeneratingMessageID"), nil) } - } else { + case inbox.ChannelLiveChat: sourceID, err = stringutil.RandomAlphanumeric(35) if err != nil { m.lo.Error("error generating random source id", "error", err) @@ -468,8 +472,8 @@ func (m *Manager) InsertMessage(message *models.Message) error { message.TextContent = stringutil.HTML2Text(message.Content) // Insert Message. - if err := m.q.InsertMessage.QueryRow(message.Type, message.Status, message.ConversationID, message.ConversationUUID, message.Content, message.TextContent, message.SenderID, message.SenderType, - message.Private, message.ContentType, message.SourceID, message.Meta).Scan(&message.ID, &message.UUID, &message.CreatedAt); err != nil { + if err := m.q.InsertMessage.Get(message, message.Type, message.Status, message.ConversationID, message.ConversationUUID, message.Content, message.TextContent, message.SenderID, message.SenderType, + message.Private, message.ContentType, message.SourceID, message.Meta); err != nil { m.lo.Error("error inserting message in db", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorInserting", "name", "{globals.terms.message}"), nil) } @@ -511,7 +515,10 @@ func (m *Manager) InsertMessage(message *models.Message) error { } } } - fmt.Println("NAME", sender.FullName()) + + // Censor CSAT content before saving last message details. + message.CensorCSATContent() + if slices.Contains([]string{models.MessageIncoming, models.MessageOutgoing}, message.Type) && !message.Private { conversationMeta["last_chat_message"] = map[string]any{ "uuid": message.UUID, @@ -521,8 +528,8 @@ func (m *Manager) InsertMessage(message *models.Message) error { "id": sender.ID, "first_name": sender.FirstName, "last_name": sender.LastName, + "type": sender.Type, }, - "sender_type": message.SenderType, } lastInteractionAt = null.TimeFrom(message.CreatedAt) } @@ -534,8 +541,8 @@ func (m *Manager) InsertMessage(message *models.Message) error { "id": sender.ID, "first_name": sender.FirstName, "last_name": sender.LastName, + "type": sender.Type, }, - "sender_type": message.SenderType, } conversationMetaB, err := json.Marshal(conversationMeta) if err != nil { @@ -669,31 +676,43 @@ func (m *Manager) getMessageActivityContent(activityType, newValue, actorName st return content, nil } -// processIncomingMessage handles the insertion of an incoming message and +// ProcessIncomingMessage handles the insertion of an incoming message and // associated contact. It finds or creates the contact, checks for existing // conversations, and creates a new conversation if necessary. It also // inserts the message, uploads any attachments, and queues the conversation evaluation of automation rules. -func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { - // Find or create contact and set sender ID in message. - if err := m.userStore.CreateContact(&in.Contact); err != nil { - m.lo.Error("error upserting contact", "error", err) - return err - } - in.Message.SenderID = in.Contact.ID +func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Message, error) { + var ( + isNewConversation = false + conversationID int + err error + ) - // Conversations exists for this message? - conversationID, err := m.findConversationID([]string{in.Message.SourceID.String}) - if err != nil && err != errConversationNotFound { - return err - } - if conversationID > 0 { - return nil - } + // Do channel specific processing. + switch in.Channel { + case inbox.ChannelEmail: + // Find or create contact and set sender ID in message. + if err := m.userStore.CreateContact(&in.Contact); err != nil { + m.lo.Error("error upserting contact", "error", err) + return models.Message{}, err + } + in.Message.SenderID = in.Contact.ID - // Find or create new conversation. - isNewConversation, err := m.findOrCreateConversation(&in.Message, in.InboxID, in.Contact.ID) - if err != nil { - return err + // Conversations exists for this message? + conversationID, err = m.findConversationID([]string{in.Message.SourceID.String}) + if err != nil && err != errConversationNotFound { + return models.Message{}, err + } + if conversationID > 0 { + return models.Message{}, nil + } + + // Find or create new conversation. + isNewConversation, err = m.findOrCreateConversation(&in.Message, in.InboxID, in.Contact.ID) + if err != nil { + return models.Message{}, err + } + case inbox.ChannelLiveChat: + // For live chat, a conversation is created before the message is processed. So nothing to do here. } // Upload message attachments. @@ -704,56 +723,15 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { // Insert message. if err = m.InsertMessage(&in.Message); err != nil { - return err + return models.Message{}, err } - // Evaluate automation rules & send webhook events. - if isNewConversation { - conversation, err := m.GetConversation(in.Message.ConversationID, "") - if err == nil { - m.webhookStore.TriggerEvent(wmodels.EventConversationCreated, conversation) - m.automation.EvaluateNewConversationRules(conversation) - } - return nil + // Process post-message hooks (automation rules, webhooks, SLA, etc.). + if err := m.ProcessIncomingMessageHooks(in.Message.ConversationUUID, isNewConversation); err != nil { + m.lo.Error("error processing incoming message hooks", "conversation_uuid", in.Message.ConversationUUID, "error", err) + return models.Message{}, fmt.Errorf("processing incoming message hooks: %w", err) } - - // Reopen conversation if it's not Open. - systemUser, err := m.userStore.GetSystemUser() - if err != nil { - m.lo.Error("error fetching system user", "error", err) - } else { - if err := m.ReOpenConversation(in.Message.ConversationUUID, systemUser); err != nil { - m.lo.Error("error reopening conversation", "error", err) - } - } - - // Set waiting since timestamp, this gets cleared when agent replies to the conversation. - now := time.Now() - m.UpdateConversationWaitingSince(in.Message.ConversationUUID, &now) - - // Create SLA event for next response if a SLA is applied and has next response time set, subsequent agent replies will mark this event as met. - // This cycle continues for next response time SLA metric. - conversation, err := m.GetConversation(in.Message.ConversationID, "") - if err != nil { - m.lo.Error("error fetching conversation", "conversation_id", in.Message.ConversationID, "error", err) - } else { - // Trigger automations on incoming message event. - m.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationMessageIncoming) - - if conversation.SLAPolicyID.Int == 0 { - m.lo.Info("no SLA policy applied to conversation, skipping next response SLA event creation") - return nil - } - if deadline, err := m.slaStore.CreateNextResponseSLAEvent(conversation.ID, conversation.AppliedSLAID.Int, conversation.SLAPolicyID.Int, conversation.AssignedTeamID.Int); err != nil && !errors.Is(err, sla.ErrUnmetSLAEventAlreadyExists) { - m.lo.Error("error creating next response SLA event", "conversation_id", conversation.ID, "error", err) - } else if !deadline.IsZero() { - m.lo.Info("next response SLA event created for conversation", "conversation_id", conversation.ID, "deadline", deadline, "sla_policy_id", conversation.SLAPolicyID.Int) - m.BroadcastConversationUpdate(in.Message.ConversationUUID, "next_response_deadline_at", deadline.Format(time.RFC3339)) - // Clear next response met at timestamp as this event was just created. - m.BroadcastConversationUpdate(in.Message.ConversationUUID, "next_response_met_at", nil) - } - } - return nil + return in.Message, nil } // MessageExists checks if a message with the given messageID exists. @@ -968,9 +946,13 @@ func (m *Manager) attachAttachmentsToMessage(message *models.Message) error { return err } attachment := attachment.Attachment{ - Name: media.Filename, - Content: blob, - Header: attachment.MakeHeader(media.ContentType, media.UUID, media.Filename, "base64", media.Disposition.String), + Name: media.Filename, + UUID: media.UUID, + ContentType: media.ContentType, + Content: blob, + Size: media.Size, + Header: attachment.MakeHeader(media.ContentType, media.UUID, media.Filename, "base64", media.Disposition.String), + URL: m.mediaStore.GetURL(media.UUID), } attachments = append(attachments, attachment) } @@ -1030,3 +1012,56 @@ func (m *Manager) getLatestMessage(conversationID int, typ []string, status []st } return message, nil } + +// ProcessIncomingMessageHooks handles automation rules, webhooks, SLA events, and other post-processing +// for incoming messages. This allows other channels to insert messages first and then call this +// function to trigger the necessary hooks. +func (m *Manager) ProcessIncomingMessageHooks(conversationUUID string, isNewConversation bool) error { + // Handle new conversation events. + if isNewConversation { + conversation, err := m.GetConversation(0, conversationUUID) + if err == nil { + m.webhookStore.TriggerEvent(wmodels.EventConversationCreated, conversation) + m.automation.EvaluateNewConversationRules(conversation) + } + return nil + } + + // Reopen conversation if it's not Open. + systemUser, err := m.userStore.GetSystemUser() + if err != nil { + m.lo.Error("error fetching system user", "error", err) + } else { + if err := m.ReOpenConversation(conversationUUID, systemUser); err != nil { + m.lo.Error("error reopening conversation", "error", err) + } + } + + // Set waiting since timestamp, this gets cleared when agent replies to the conversation. + now := time.Now() + m.UpdateConversationWaitingSince(conversationUUID, &now) + + // Create SLA event for next response if a SLA is applied and has next response time set, subsequent agent replies will mark this event as met. + // This cycle continues for next response time SLA metric. + conversation, err := m.GetConversation(0, conversationUUID) + if err != nil { + m.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err) + } else { + // Trigger automations on incoming message event. + m.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationMessageIncoming) + + if conversation.SLAPolicyID.Int == 0 { + m.lo.Info("no SLA policy applied to conversation, skipping next response SLA event creation") + return nil + } + if deadline, err := m.slaStore.CreateNextResponseSLAEvent(conversation.ID, conversation.AppliedSLAID.Int, conversation.SLAPolicyID.Int, conversation.AssignedTeamID.Int); err != nil && !errors.Is(err, sla.ErrUnmetSLAEventAlreadyExists) { + m.lo.Error("error creating next response SLA event", "conversation_id", conversation.ID, "error", err) + } else if !deadline.IsZero() { + m.lo.Info("next response SLA event created for conversation", "conversation_id", conversation.ID, "deadline", deadline, "sla_policy_id", conversation.SLAPolicyID.Int) + m.BroadcastConversationUpdate(conversationUUID, "next_response_deadline_at", deadline.Format(time.RFC3339)) + // Clear next response met at timestamp as this event was just created. + m.BroadcastConversationUpdate(conversationUUID, "next_response_met_at", nil) + } + } + return nil +} diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go index 9f5e468e..11b2ea07 100644 --- a/internal/conversation/models/models.go +++ b/internal/conversation/models/models.go @@ -3,6 +3,7 @@ package models import ( "encoding/json" "net/textproto" + "strings" "time" "github.com/abhinavxd/libredesk/internal/attachment" @@ -52,26 +53,31 @@ var ( ContentTypeHTML = "html" ) +type LastChatMessage struct { + Content string `db:"content" json:"content"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Author umodels.ChatUser `db:"author" json:"author"` +} + type ChatConversation struct { - UUID string `db:"uuid" json:"uuid"` - Status string `db:"status" json:"status"` - LastMessage string `db:"last_message" json:"last_message"` - LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"` - LastMessageSenderFirstName string `db:"last_message_sender_first_name" json:"last_message_sender_first_name"` - LastMessageSenderLastName string `db:"last_message_sender_last_name" json:"last_message_sender_last_name"` - LastMessageSenderAvatarURL string `db:"last_message_sender_avatar_url" json:"last_message_sender_avatar_url"` - UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"` - Assignee umodels.User `db:"assignee" json:"assignee"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UUID string `db:"uuid" json:"uuid"` + Status string `db:"status" json:"status"` + LastChatMessage LastChatMessage `db:"last_message" json:"last_message"` + UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"` + Assignee umodels.ChatUser `db:"assignee" json:"assignee"` } type ChatMessage struct { - CreatedAt time.Time `json:"created_at"` - UUID string `json:"uuid"` - Content string `json:"content"` - SenderType string `json:"sender_type"` - SenderName string `json:"sender_name"` - ConversationID string `json:"conversation_id"` - Attachments attachment.Attachments `json:"attachments"` + UUID string `json:"uuid"` + Status string `json:"status"` + ConversationUUID string `json:"conversation_uuid"` + CreatedAt time.Time `json:"created_at"` + Content string `json:"content"` + TextContent string `json:"text_content"` + Author umodels.ChatUser `json:"author"` + Attachments attachment.Attachments `json:"attachments"` + Meta json.RawMessage `json:"meta"` } type Conversation struct { @@ -118,6 +124,13 @@ type Conversation struct { Total int `db:"total" json:"-"` } +type IncomingMessage struct { + Channel string + Message Message + Contact umodels.User + InboxID int +} + type ConversationParticipant struct { ID string `db:"id" json:"id"` FirstName string `db:"first_name" json:"first_name"` @@ -139,39 +152,38 @@ type NewConversationsStats struct { // Message represents a message in a conversation type Message struct { - ID int `db:"id" json:"id,omitempty"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - UUID string `db:"uuid" json:"uuid"` - Type string `db:"type" json:"type"` - Status string `db:"status" json:"status"` - ConversationID int `db:"conversation_id" json:"conversation_id"` - Content string `db:"content" json:"content"` - TextContent string `db:"text_content" json:"text_content"` - ContentType string `db:"content_type" json:"content_type"` - Private bool `db:"private" json:"private"` - SourceID null.String `db:"source_id" json:"-"` - SenderID int `db:"sender_id" json:"sender_id"` - SenderType string `db:"sender_type" json:"sender_type"` - InboxID int `db:"inbox_id" json:"-"` - Meta json.RawMessage `db:"meta" json:"meta"` - Attachments attachment.Attachments `db:"attachments" json:"attachments"` - ConversationUUID string `db:"conversation_uuid" json:"-"` - From string `db:"from" json:"-"` - Subject string `db:"subject" json:"-"` - Channel string `db:"channel" json:"-"` - To pq.StringArray `db:"to" json:"-"` - CC pq.StringArray `db:"cc" json:"-"` - BCC pq.StringArray `db:"bcc" json:"-"` - References []string `json:"-"` - InReplyTo string `json:"-"` - Headers textproto.MIMEHeader `json:"-"` - AltContent string `db:"-" json:"-"` - Media []mmodels.Media `db:"-" json:"-"` - IsCSAT bool `db:"-" json:"-"` - // TODO: Figure if this field is needed or there's a better way. - MessageReceiverID int `db:"-" json:"-"` - Total int `db:"total" json:"-"` + ID int `db:"id" json:"id,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + UUID string `db:"uuid" json:"uuid"` + Type string `db:"type" json:"type"` + Status string `db:"status" json:"status"` + ConversationID int `db:"conversation_id" json:"conversation_id"` + Content string `db:"content" json:"content"` + TextContent string `db:"text_content" json:"text_content"` + ContentType string `db:"content_type" json:"content_type"` + Private bool `db:"private" json:"private"` + SourceID null.String `db:"source_id" json:"-"` + SenderID int `db:"sender_id" json:"sender_id"` + SenderType string `db:"sender_type" json:"sender_type"` + InboxID int `db:"inbox_id" json:"-"` + Meta json.RawMessage `db:"meta" json:"meta"` + Attachments attachment.Attachments `db:"attachments" json:"attachments"` + ConversationUUID string `db:"conversation_uuid" json:"-"` + From string `db:"from" json:"-"` + Subject string `db:"subject" json:"-"` + Channel string `db:"channel" json:"-"` + To pq.StringArray `db:"to" json:"-"` + CC pq.StringArray `db:"cc" json:"-"` + BCC pq.StringArray `db:"bcc" json:"-"` + MessageReceiverID int `db:"message_receiver_id" json:"-"` + References []string `json:"-"` + InReplyTo string `json:"-"` + Headers textproto.MIMEHeader `json:"-"` + AltContent string `db:"-" json:"-"` + Media []mmodels.Media `db:"-" json:"-"` + IsCSAT bool `db:"-" json:"-"` + Total int `db:"total" json:"-"` } // CensorCSATContent redacts the content of a CSAT message to prevent leaking the CSAT survey public link. @@ -181,11 +193,40 @@ func (m *Message) CensorCSATContent() { return } if isCsat, _ := meta["is_csat"].(bool); isCsat { - m.Content = "Please rate your experience with us" + m.Content = "Please rate this conversation" m.TextContent = m.Content } } +// CensorCSATContentWithStatus redacts the content and adds submission status for CSAT messages. +func (m *Message) CensorCSATContentWithStatus(csatSubmitted bool, csatUUID string, rating int, feedback string) { + var meta map[string]any + if err := json.Unmarshal([]byte(m.Meta), &meta); err != nil { + return + } + if isCsat, _ := meta["is_csat"].(bool); isCsat { + m.Content = "Please rate this conversation" + m.TextContent = m.Content + + // Add submission status and UUID to meta + meta["csat_submitted"] = csatSubmitted + meta["csat_uuid"] = csatUUID + + // Add submitted rating and feedback if CSAT was submitted + if csatSubmitted { + if rating > 0 { + meta["submitted_rating"] = rating + } + meta["submitted_feedback"] = feedback + } + + // Update the meta field + if updatedMeta, err := json.Marshal(meta); err == nil { + m.Meta = json.RawMessage(updatedMeta) + } + } +} + // HasCSAT returns true if the message is a CSAT message. func (m *Message) HasCSAT() bool { var meta map[string]interface{} @@ -196,11 +237,35 @@ func (m *Message) HasCSAT() bool { return isCsat } -// IncomingMessage links a message with the contact information and inbox id. -type IncomingMessage struct { - Message Message - Contact umodels.User - InboxID int +// ExtractCSATUUID extracts the CSAT UUID from the message content. +func (m *Message) ExtractCSATUUID() string { + if !m.HasCSAT() { + return "" + } + + // Extract UUID from the CSAT URL in the message content + // Pattern: + content := m.Content + // Look for /csat/ followed by UUID pattern + start := strings.Index(content, "/csat/") + if start == -1 { + return "" + } + start += 6 // Skip "/csat/" + + // Find the end of UUID (36 characters) + if len(content) < start+36 { + return "" + } + + uuid := content[start : start+36] + + // Basic validation - UUID should contain hyphens at positions 8, 13, 18, 23 + if len(uuid) == 36 && uuid[8] == '-' && uuid[13] == '-' && uuid[18] == '-' && uuid[23] == '-' { + return uuid + } + + return "" } type Status struct { diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index d5c0a14b..923c186a 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -222,24 +222,35 @@ LIMIT 10; -- name: get-contact-chat-conversations SELECT + c.created_at, c.uuid, - COALESCE(c.meta->'last_chat_message'->>'text_content', '') as last_message, - COALESCE((c.meta->'last_chat_message'->>'created_at')::timestamptz, NULL) as last_message_at, - COALESCE(c.meta->'last_chat_message'->'sender'->>'first_name', '') AS last_message_sender_first_name, - COALESCE(c.meta->'last_chat_message'->'sender'->>'last_name', '') AS last_message_sender_last_name, - COALESCE(c.meta->'last_chat_message'->'sender'->>'avatar_url', '') AS last_message_sender_avatar_url, - LEAST(10, COUNT(unread.id)) AS unread_message_count -FROM conversations c + COALESCE(c.meta->'last_chat_message'->>'text_content', '') as "last_message.content", + COALESCE((c.meta->'last_chat_message'->>'created_at')::timestamptz, NULL) as "last_message.created_at", + COALESCE(c.meta->'last_chat_message'->'sender'->>'id', '') AS "last_message.author.id", + COALESCE(c.meta->'last_chat_message'->'sender'->>'first_name', '') AS "last_message.author.first_name", + COALESCE(c.meta->'last_chat_message'->'sender'->>'last_name', '') AS "last_message.author.last_name", + COALESCE(c.meta->'last_chat_message'->'sender'->>'avatar_url', '') AS "last_message.author.avatar_url", + LEAST(10, COUNT(unread.id)) AS unread_message_count, + COALESCE(au.availability_status::TEXT, '') as "assignee.availability_status", + au.avatar_url as "assignee.avatar_url", + COALESCE(au.first_name, '') as "assignee.first_name", + COALESCE(au.id, 0) as "assignee.id", + COALESCE(au.last_name, '') as "assignee.last_name", + COALESCE(au.type::TEXT, '') as "assignee.type" +FROM conversations c inner join inboxes inb on c.inbox_id = inb.id +LEFT JOIN users au ON c.assigned_user_id = au.id LEFT JOIN conversation_messages unread ON unread.conversation_id = c.id AND unread.created_at > c.contact_last_seen_at AND unread.type IN ('incoming', 'outgoing') AND unread.private = false -WHERE c.contact_id = $1 -GROUP BY c.id, c.uuid, +WHERE c.contact_id = $1 AND inb.channel = 'livechat' AND inb.deleted_at IS NULL AND inb.enabled = true +GROUP BY c.id, c.uuid, c.created_at, c.meta->'last_chat_message'->>'text_content', (c.meta->'last_chat_message'->>'created_at')::timestamptz, + c.meta->'last_chat_message'->'sender'->>'id', c.meta->'last_chat_message'->'sender'->>'first_name', c.meta->'last_chat_message'->'sender'->>'last_name', - c.meta->'last_chat_message'->'sender'->>'avatar_url' + c.meta->'last_chat_message'->'sender'->>'avatar_url', + au.availability_status, au.avatar_url, au.first_name, au.id, au.last_name, au.type ORDER BY c.created_at DESC LIMIT 100; @@ -473,15 +484,19 @@ SELECT m.private, m.status, m.content, + m.text_content, + m.sender_type, m.conversation_id, m.content_type, m.source_id, + m.meta, ARRAY(SELECT jsonb_array_elements_text(m.meta->'cc')) AS cc, ARRAY(SELECT jsonb_array_elements_text(m.meta->'bcc')) AS bcc, ARRAY(SELECT jsonb_array_elements_text(m.meta->'to')) AS to, c.inbox_id, c.uuid as conversation_uuid, - c.subject + c.subject, + c.contact_id as message_receiver_id FROM conversation_messages m INNER JOIN conversations c ON c.id = m.conversation_id WHERE m.status = 'pending' AND m.type = 'outgoing' AND m.private = false diff --git a/internal/conversation/ws.go b/internal/conversation/ws.go index 7a6aaa91..1799a4c8 100644 --- a/internal/conversation/ws.go +++ b/internal/conversation/ws.go @@ -5,6 +5,7 @@ import ( "time" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" wsmodels "github.com/abhinavxd/libredesk/internal/ws/models" ) @@ -51,6 +52,37 @@ func (m *Manager) BroadcastConversationUpdate(conversationUUID, prop string, val m.broadcastToUsers([]int{}, message) } +// BroadcastTypingToConversation broadcasts typing status to all subscribers of a conversation. +// Set broadcastToWidgets to false when the typing event originates from a widget client to avoid echo. +func (m *Manager) BroadcastTypingToConversation(conversationUUID string, isTyping bool, broadcastToWidgets bool) { + message := wsmodels.Message{ + Type: wsmodels.MessageTypeTyping, + Data: map[string]interface{}{ + "conversation_uuid": conversationUUID, + "is_typing": isTyping, + }, + } + + messageBytes, err := json.Marshal(message) + if err != nil { + m.lo.Error("error marshalling typing WS message", "error", err) + return + } + + // Always broadcast to agent clients (main app WebSocket clients) + m.wsHub.BroadcastTypingToAllConversationClients(conversationUUID, messageBytes) + + // Broadcast to widget clients (customers) only if this typing event comes from agents + if broadcastToWidgets { + m.broadcastTypingToWidgetClients(conversationUUID, isTyping) + } +} + +// BroadcastTypingToWidgetClientsOnly broadcasts typing status only to widget clients. +func (m *Manager) BroadcastTypingToWidgetClientsOnly(conversationUUID string, isTyping bool) { + m.broadcastTypingToWidgetClients(conversationUUID, isTyping) +} + // broadcastToUsers broadcasts a message to a list of users, if the list is empty it broadcasts to all users. func (m *Manager) broadcastToUsers(userIDs []int, message wsmodels.Message) { messageBytes, err := json.Marshal(message) @@ -63,3 +95,25 @@ func (m *Manager) broadcastToUsers(userIDs []int, message wsmodels.Message) { Users: userIDs, }) } + +// broadcastTypingToWidgetClients broadcasts typing status to widget clients (customers) for a conversation. +func (m *Manager) broadcastTypingToWidgetClients(conversationUUID string, isTyping bool) { + // Get the conversation to find its inbox ID + conversation, err := m.GetConversation(0, conversationUUID) + if err != nil { + m.lo.Error("error getting conversation for widget typing broadcast", "error", err, "conversation_uuid", conversationUUID) + return + } + + // Get the inbox + inboxInstance, err := m.inboxStore.Get(conversation.InboxID) + if err != nil { + m.lo.Error("error getting inbox for widget typing broadcast", "error", err, "inbox_id", conversation.InboxID) + return + } + + // Check if it's a livechat inbox and broadcast typing status + if liveChatInbox, ok := inboxInstance.(*livechat.LiveChat); ok { + liveChatInbox.BroadcastTypingToClients(conversationUUID, isTyping) + } +} diff --git a/internal/csat/csat.go b/internal/csat/csat.go index 58b6bef6..46d67b3d 100644 --- a/internal/csat/csat.go +++ b/internal/csat/csat.go @@ -93,7 +93,8 @@ func (m *Manager) UpdateResponse(uuid string, score int, feedback string) error return err } - if csat.Score > 0 || !csat.ResponseTimestamp.IsZero() { + // Check if CSAT has already been submitted (response timestamp exists) + if !csat.ResponseTimestamp.IsZero() { return envelope.NewError(envelope.InputError, m.i18n.T("csat.alreadySubmitted"), nil) } diff --git a/internal/httputil/httputil.go b/internal/httputil/httputil.go new file mode 100644 index 00000000..2450154e --- /dev/null +++ b/internal/httputil/httputil.go @@ -0,0 +1,90 @@ +package httputil + +import ( + "net" + "net/url" + "strings" +) + +// IsOriginTrusted checks if the given origin is trusted based on the trusted domains list +// Expects trustedDomains to be a list of domain strings, which can include wildcards. +// Like "*.example.com" or "example.com". +func IsOriginTrusted(origin string, trustedDomains []string) bool { + if len(trustedDomains) == 0 { + return false + } + + originHost, originPort := parseHostPort(origin) + if originHost == "" { + return false + } + + for _, trusted := range trustedDomains { + trustedHost, trustedPort := parseTrustedDomain(trusted) + if portMatches(originPort, trustedPort) && hostMatches(originHost, trustedHost) { + return true + } + } + + return false +} + +// parseHostPort extracts host and port from origin URL +func parseHostPort(origin string) (host, port string) { + u, err := url.Parse(strings.ToLower(origin)) + if err != nil { + return "", "" + } + + host, port, _ = net.SplitHostPort(u.Host) + if host == "" { + host = u.Host + } + return host, port +} + +// parseTrustedDomain extracts host and port from trusted domain entry +func parseTrustedDomain(domain string) (host, port string) { + domain = strings.ToLower(domain) + + if strings.HasPrefix(domain, "http://") || strings.HasPrefix(domain, "https://") { + u, err := url.Parse(domain) + if err != nil { + return "", "" + } + host, port, _ = net.SplitHostPort(u.Host) + if host == "" { + host = u.Host + } + return host, port + } + + // Handle non-URL patterns (wildcards/domains) + host, port, _ = net.SplitHostPort(domain) + if host == "" { + host = domain + } + return host, port +} + +// portMatches checks if ports are compatible +func portMatches(originPort, trustedPort string) bool { + if trustedPort == "" || trustedPort == originPort { + return true + } + return false +} + +// hostMatches checks if host matches trusted pattern +func hostMatches(origin, trusted string) bool { + if trusted == origin { + return true + } + + if strings.HasPrefix(trusted, "*.") { + base := trusted[2:] + return origin == base || strings.HasSuffix(origin, "."+base) + } + + return false +} \ No newline at end of file diff --git a/internal/inbox/channel/email/imap.go b/internal/inbox/channel/email/imap.go index 0795b232..29c5655e 100644 --- a/internal/inbox/channel/email/imap.go +++ b/internal/inbox/channel/email/imap.go @@ -340,6 +340,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, return fmt.Errorf("marshalling meta: %w", err) } incomingMsg := models.IncomingMessage{ + Channel: ChannelEmail, Message: models.Message{ Channel: e.Channel(), SenderType: models.SenderTypeContact, diff --git a/internal/inbox/channel/livechat/livechat.go b/internal/inbox/channel/livechat/livechat.go index b829aad5..e7f154f0 100644 --- a/internal/inbox/channel/livechat/livechat.go +++ b/internal/inbox/channel/livechat/livechat.go @@ -7,10 +7,10 @@ import ( "fmt" "strconv" "sync" - "time" "github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/inbox" + umodels "github.com/abhinavxd/libredesk/internal/user/models" "github.com/zerodha/logf" ) @@ -26,6 +26,7 @@ const ( // Config holds the live chat inbox configuration. type Config struct { BrandName string `json:"brand_name"` + DarkMode bool `json:"dark_mode"` Language string `json:"language"` Users struct { AllowStartConversation bool `json:"allow_start_conversation"` @@ -67,6 +68,7 @@ type Config struct { IntroductionMessage string `json:"introduction_message"` ShowOfficeHoursInChat bool `json:"show_office_hours_in_chat"` ShowOfficeHoursAfterAssignment bool `json:"show_office_hours_after_assignment"` + ChatReplyExpectationMessage string `json:"chat_reply_expectation_message"` } // Client represents a connected chat client @@ -137,38 +139,49 @@ func (lc *LiveChat) Send(message models.Message) error { } for _, client := range clients { + // Set `content` in all attachments to `null` as attachments are sent with URLs and live chat uses URLs to fetch the content. + for i := range message.Attachments { + if message.Attachments[i].Content != nil { + message.Attachments[i].Content = nil + } + } + messageData := map[string]any{ "type": "new_message", - "data": map[string]any{ - "created_at": message.CreatedAt.Format(time.RFC3339), - "conversation_uuid": message.ConversationUUID, - "uuid": message.UUID, - "content": message.Content, - "text_content": message.TextContent, - "sender_type": message.SenderType, - "sender_name": sender.FullName(), - "status": message.Status, + "data": models.ChatMessage{ + UUID: message.UUID, + ConversationUUID: message.ConversationUUID, + CreatedAt: message.CreatedAt, + Content: message.Content, + TextContent: message.TextContent, + Meta: message.Meta, + Author: umodels.ChatUser{ + ID: message.SenderID, + FirstName: sender.FirstName, + LastName: sender.LastName, + AvatarURL: sender.AvatarURL, + AvailabilityStatus: sender.AvailabilityStatus, + Type: sender.Type, + }, + Attachments: message.Attachments, }, } - // Convert messageData to JSON + // Marshal and send to client's channel. messageJSON, err := json.Marshal(messageData) if err != nil { lc.lo.Error("failed to marshal message data", "error", err) continue } - - // Send the message to the client's channel select { case client.Channel <- messageJSON: lc.lo.Info("message sent to live chat client", "client_id", client.ID, "message_id", message.UUID) default: lc.lo.Warn("client channel full, dropping message", "client_id", client.ID, "message_id", message.UUID) } - continue } } else { - lc.lo.Debug("client not connected for live chat message", "receiver_id", msgReceiverStr, "message_id", message.UUID) + lc.lo.Debug("websocket client not connected for live chat message", "receiver_id", msgReceiverStr, "message_id", message.UUID) return ErrClientNotConnected } } @@ -192,7 +205,7 @@ func (lc *LiveChat) Channel() string { } // AddClient adds a new client to the live chat session. -func (lc *LiveChat) AddClient(userID, conversationUUID string) (*Client, error) { +func (lc *LiveChat) AddClient(userID string) (*Client, error) { lc.clientsMutex.Lock() defer lc.clientsMutex.Unlock() @@ -209,8 +222,6 @@ func (lc *LiveChat) AddClient(userID, conversationUUID string) (*Client, error) // Add the client to the clients map. lc.clients[userID] = append(lc.clients[userID], client) - - lc.lo.Info("client added to live chat", "client_id", userID, "conversation_uuid", conversationUUID) return client, nil } @@ -235,3 +246,36 @@ func (lc *LiveChat) RemoveClient(c *Client) { } } } + +// BroadcastTypingToClients broadcasts typing status to all connected widget clients for a conversation. +func (lc *LiveChat) BroadcastTypingToClients(conversationUUID string, isTyping bool) { + lc.clientsMutex.RLock() + defer lc.clientsMutex.RUnlock() + + // Create typing status message for widget clients + typingMessage := map[string]interface{}{ + "type": "typing", + "data": map[string]interface{}{ + "conversation_uuid": conversationUUID, + "is_typing": isTyping, + }, + } + + messageJSON, err := json.Marshal(typingMessage) + if err != nil { + lc.lo.Error("failed to marshal typing message", "error", err) + return + } + + // Broadcast to all connected clients + for userID, clients := range lc.clients { + for _, client := range clients { + select { + case client.Channel <- messageJSON: + lc.lo.Debug("typing status sent to widget client", "user_id", userID, "client_id", client.ID, "conversation_uuid", conversationUUID, "is_typing", isTyping) + default: + lc.lo.Warn("client channel full, dropping typing message", "user_id", userID, "client_id", client.ID) + } + } + } +} diff --git a/internal/media/media.go b/internal/media/media.go index 26c6318f..1ff4c322 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -18,6 +18,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/knadh/go-i18n" "github.com/volatiletech/null/v9" + "github.com/zerodha/fastglue" "github.com/zerodha/logf" ) @@ -35,19 +36,29 @@ type Store interface { Name() string } +// SignedURLStore defines the interface for stores that support signed URLs. +// This is optional and only implemented by stores that need signed URL functionality (like fs). +type SignedURLStore interface { + Store + GetSignedURL(name string, expiresAt time.Time, secret []byte) string + VerifySignature(name, signature string, expiresAt time.Time, secret []byte) bool +} + type Manager struct { store Store lo *logf.Logger i18n *i18n.I18n queries queries + secret string } // Opts provides options for configuring the Manager. type Opts struct { - Store Store - Lo *logf.Logger - DB *sqlx.DB - I18n *i18n.I18n + Store Store + Lo *logf.Logger + DB *sqlx.DB + I18n *i18n.I18n + Secret string } // New initializes and returns a new Manager instance for handling media operations. @@ -61,6 +72,7 @@ func New(opt Opts) (*Manager, error) { lo: opt.Lo, i18n: opt.I18n, queries: q, + secret: opt.Secret, }, nil } @@ -217,3 +229,49 @@ func (m *Manager) deleteUnlinkedMessageMedia() error { } return nil } + +// GetSignedURL returns a signed URL for accessing a media file with expiration. +// This delegates to the store if it supports signed URLs (like fs), otherwise returns the normal URL. +func (m *Manager) GetSignedURL(name string, expiresAt time.Time) string { + // Check if the store supports signed URLs + if signedStore, ok := m.store.(SignedURLStore); ok { + return signedStore.GetSignedURL(name, expiresAt, []byte(m.secret)) + } + // Fallback to regular URL for stores that handle signing internally (like S3) + return m.store.GetURL(name) +} + +// VerifySignature verifies a signed URL signature using the request parameters. +// This is used by middleware to verify widget media access. +func (m *Manager) VerifySignature(r *fastglue.Request) error { + uuid := r.RequestCtx.UserValue("uuid") + if uuid == nil { + return fmt.Errorf("missing uuid parameter") + } + + signature := string(r.RequestCtx.QueryArgs().Peek("signature")) + expiresStr := string(r.RequestCtx.QueryArgs().Peek("expires")) + + if signature == "" || expiresStr == "" { + return fmt.Errorf("missing signature or expires parameter") + } + + // Parse expiration time + var expires int64 + if _, err := fmt.Sscanf(expiresStr, "%d", &expires); err != nil { + return fmt.Errorf("invalid expires parameter: %v", err) + } + + expiresAt := time.Unix(expires, 0) + + // Check if store supports signature verification + if signedStore, ok := m.store.(SignedURLStore); ok { + if !signedStore.VerifySignature(uuid.(string), signature, expiresAt, []byte(m.secret)) { + return fmt.Errorf("signature verification failed") + } + return nil + } + + // For stores that don't support signing (like S3), always allow + return nil +} diff --git a/internal/media/models/models.go b/internal/media/models/models.go index f4a05734..f72b185d 100644 --- a/internal/media/models/models.go +++ b/internal/media/models/models.go @@ -1,6 +1,7 @@ package models import ( + "encoding/json" "time" "github.com/volatiletech/null/v9" @@ -15,17 +16,18 @@ const ( // Media represents an uploaded object. type Media struct { - ID int `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UUID string `db:"uuid" json:"uuid"` - Filename string `db:"filename" json:"filename"` - ContentType string `db:"content_type" json:"content_type"` - Model null.String `db:"model_type" json:"-"` - ModelID null.Int `db:"model_id" json:"-"` - Size int `db:"size" json:"size"` - Store string `db:"store" json:"store"` - Disposition null.String `db:"disposition" json:"disposition"` - URL string `json:"url"` - ContentID string `json:"-"` - Content []byte `json:"-"` + ID int `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UUID string `db:"uuid" json:"uuid"` + Filename string `db:"filename" json:"filename"` + ContentType string `db:"content_type" json:"content_type"` + Model null.String `db:"model_type" json:"-"` + ModelID null.Int `db:"model_id" json:"-"` + Size int `db:"size" json:"size"` + Store string `db:"store" json:"store"` + Disposition null.String `db:"disposition" json:"disposition"` + Meta json.RawMessage `db:"meta" json:"meta"` + URL string `json:"url"` + ContentID string `json:"-"` + Content []byte `json:"-"` } diff --git a/internal/migrations/v0.8.0.go b/internal/migrations/v0.8.0.go index 46f2a606..60f3edee 100644 --- a/internal/migrations/v0.8.0.go +++ b/internal/migrations/v0.8.0.go @@ -8,13 +8,27 @@ import ( // V0_8_0 updates the database schema to v0.8.0. func V0_8_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { - // Add 'livechat' to the channels enum - _, err := db.Exec(` - ALTER TYPE channels ADD VALUE IF NOT EXISTS 'livechat'; + // Add 'livechat' to the channels enum if not already present + var exists bool + err := db.Get(&exists, ` + SELECT EXISTS ( + SELECT 1 + FROM pg_enum + WHERE enumlabel = 'livechat' + AND enumtypid = ( + SELECT oid FROM pg_type WHERE typname = 'channels' + ) + ) `) if err != nil { return err } + if !exists { + _, err = db.Exec(`ALTER TYPE channels ADD VALUE 'livechat'`) + if err != nil { + return err + } + } // Drop the foreign key constraint and column from conversations table first _, err = db.Exec(` @@ -64,5 +78,34 @@ func V0_8_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { return err } + tx, err := db.Beginx() + if err != nil { + return err + } + defer tx.Rollback() + stmts := []string{ + /* ── drop index for e‑mail uniqueness and add seperate indexes for type of user ── */ + `DROP INDEX IF EXISTS index_unique_users_on_email_and_type_when_deleted_at_is_null`, + + /* ── add separate indexes for type of user, this excludes `visitor` type as there can be multiple visitors with the same email ── */ + `CREATE UNIQUE INDEX IF NOT EXISTS + index_unique_users_on_email_when_type_is_contact + ON users(email) + WHERE type = 'contact' AND deleted_at IS NULL`, + + `CREATE UNIQUE INDEX IF NOT EXISTS + index_unique_users_on_email_when_type_is_agent + ON users(email) + WHERE type = 'agent' AND deleted_at IS NULL`, + } + + for _, q := range stmts { + if _, err = tx.Exec(q); err != nil { + return err + } + } + if err := tx.Commit(); err != nil { + return err + } return nil } diff --git a/internal/user/models/models.go b/internal/user/models/models.go index 42ef9064..e7289d58 100644 --- a/internal/user/models/models.go +++ b/internal/user/models/models.go @@ -44,7 +44,7 @@ type User struct { PhoneNumber null.String `db:"phone_number" json:"phone_number"` AvatarURL null.String `db:"avatar_url" json:"avatar_url"` Enabled bool `db:"enabled" json:"enabled"` - Password string `db:"password" json:"-"` + Password null.String `db:"password" json:"-"` LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"` LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"` Roles pq.StringArray `db:"roles" json:"roles"` @@ -63,6 +63,17 @@ type User struct { Total int `json:"total,omitempty"` } +// ChatUser is a user with limited fields for live chat. +type ChatUser struct { + ID int `db:"id" json:"id"` + FirstName string `db:"first_name" json:"first_name"` + LastName string `db:"last_name" json:"last_name"` + AvatarURL null.String `db:"avatar_url" json:"avatar_url"` + AvailabilityStatus string `db:"availability_status" json:"availability_status"` + Type string `db:"type" json:"type"` + ActiveAt null.Time `db:"active_at" json:"active_at"` +} + type Note struct { ID int `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` diff --git a/internal/user/queries.sql b/internal/user/queries.sql index 08f04b8e..571ecf89 100644 --- a/internal/user/queries.sql +++ b/internal/user/queries.sql @@ -63,7 +63,10 @@ FROM users u LEFT JOIN user_roles ur ON ur.user_id = u.id LEFT JOIN roles r ON r.id = ur.role_id LEFT JOIN LATERAL unnest(r.permissions) AS p ON true -WHERE (u.id = $1 OR u.email = $2) AND u.type = $3 AND u.deleted_at IS NULL +WHERE u.deleted_at IS NULL + AND ($1 = 0 OR u.id = $1) + AND ($2 = '' OR u.email = $2) + AND ($3 = '' OR u.type::text = $3) GROUP BY u.id; -- name: set-user-password @@ -152,16 +155,14 @@ RETURNING user_id; -- name: insert-contact INSERT INTO users (email, type, first_name, last_name, "password", avatar_url) VALUES ($1, 'contact', $2, $3, $4, $5) -ON CONFLICT (email, type) WHERE deleted_at IS NULL +ON CONFLICT (email) WHERE type = 'contact' AND deleted_at IS NULL DO UPDATE SET updated_at = now() RETURNING id; -- name: insert-visitor -INSERT INTO users (email, type, first_name, last_name, "password", avatar_url) -VALUES ($1, 'visitor', $2, $3, $4, $5) -ON CONFLICT (email, type) WHERE deleted_at IS NULL -DO UPDATE SET updated_at = now() -RETURNING id; +INSERT INTO users (email, type, first_name, last_name) +VALUES ($1, 'visitor', $2, $3) +RETURNING *; -- name: update-last-login-at UPDATE users diff --git a/internal/user/user.go b/internal/user/user.go index 83ce8511..23b9c0a0 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -115,7 +115,7 @@ func (u *Manager) VerifyPassword(email string, password []byte) (models.User, er u.lo.Error("error fetching user from db", "error", err) return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil) } - if err := u.verifyPassword(password, user.Password); err != nil { + if err := u.verifyPassword(password, user.Password.String); err != nil { return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil) } return user, nil @@ -151,6 +151,10 @@ func (u *Manager) GetAllUsers(page, pageSize int, userType, order, orderBy strin // Get retrieves an user by ID or email. func (u *Manager) Get(id int, email, type_ string) (models.User, error) { + if id == 0 && email == "" { + return models.User{}, envelope.NewError(envelope.InputError, u.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.user}"), nil) + } + var user models.User if err := u.q.GetUser.Get(&user, id, email, type_); err != nil { if errors.Is(err, sql.ErrNoRows) { diff --git a/internal/user/visitor.go b/internal/user/visitor.go index afda91e4..3f9948b2 100644 --- a/internal/user/visitor.go +++ b/internal/user/visitor.go @@ -11,12 +11,6 @@ import ( // CreateVisitor creates a new visitor user. func (u *Manager) CreateVisitor(user *models.User) error { - password, err := u.generatePassword() - if err != nil { - u.lo.Error("generating password", "error", err) - return fmt.Errorf("generating password: %w", err) - } - // Normalize email address. user.Email = null.NewString(strings.ToLower(user.Email.String), user.Email.Valid) @@ -25,7 +19,7 @@ func (u *Manager) CreateVisitor(user *models.User) error { user.FirstName = h.Haikunate() } - if err := u.q.InsertVisitor.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL).Scan(&user.ID); err != nil { + if err := u.q.InsertVisitor.Get(user, user.Email, user.FirstName, user.LastName); err != nil { u.lo.Error("error inserting contact", "error", err) return fmt.Errorf("insert contact: %w", err) } diff --git a/internal/ws/client.go b/internal/ws/client.go index 0720826f..ee7e6b3b 100644 --- a/internal/ws/client.go +++ b/internal/ws/client.go @@ -100,7 +100,84 @@ func (c *Client) processIncomingMessage(data []byte) { c.SendMessage([]byte("pong"), websocket.TextMessage) return } - c.SendError("unknown incoming message type") + + // Try to parse as JSON message + var msg models.Message + if err := json.Unmarshal(data, &msg); err != nil { + c.SendError("invalid message format") + return + } + + switch msg.Type { + case models.MessageTypeConversationSubscribe: + c.handleConversationSubscribe(msg.Data) + case models.MessageTypeTyping: + c.handleTyping(msg.Data) + default: + c.SendError("unknown message type") + } +} + +// handleConversationSubscribe handles conversation subscription requests. +func (c *Client) handleConversationSubscribe(data interface{}) { + // Convert the data to JSON and then unmarshal to ConversationSubscribe + dataBytes, err := json.Marshal(data) + if err != nil { + c.SendError("invalid subscription data") + return + } + + var subscribeMsg models.ConversationSubscribe + if err := json.Unmarshal(dataBytes, &subscribeMsg); err != nil { + c.SendError("invalid subscription format") + return + } + + if subscribeMsg.ConversationUUID == "" { + c.SendError("conversation_uuid is required") + return + } + + // Subscribe to the conversation using the Hub + c.Hub.SubscribeToConversation(c, subscribeMsg.ConversationUUID) + + // Send confirmation back to client + response := models.Message{ + Type: models.MessageTypeConversationSubscribed, + Data: map[string]string{ + "conversation_uuid": subscribeMsg.ConversationUUID, + }, + } + + responseBytes, _ := json.Marshal(response) + c.SendMessage(responseBytes, websocket.TextMessage) +} + +// handleTyping handles typing indicator messages. +func (c *Client) handleTyping(data interface{}) { + // Convert the data to JSON and then unmarshal to TypingMessage + dataBytes, err := json.Marshal(data) + if err != nil { + c.SendError("invalid typing data") + return + } + + var typingMsg models.TypingMessage + if err := json.Unmarshal(dataBytes, &typingMsg); err != nil { + c.SendError("invalid typing format") + return + } + + if typingMsg.ConversationUUID == "" { + c.SendError("conversation_uuid is required for typing") + return + } + + // Set the user ID from the client + typingMsg.UserID = c.ID + + // Broadcast typing status to all subscribers of this conversation (except sender) + c.Hub.BroadcastTypingToConversation(typingMsg.ConversationUUID, typingMsg, c) } // close closes the client connection. diff --git a/internal/ws/models/models.go b/internal/ws/models/models.go index 8b38b481..ec65af07 100644 --- a/internal/ws/models/models.go +++ b/internal/ws/models/models.go @@ -7,6 +7,9 @@ const ( MessageTypeNewMessage = "new_message" MessageTypeNewConversation = "new_conversation" MessageTypeError = "error" + MessageTypeConversationSubscribe = "conversation_subscribe" + MessageTypeConversationSubscribed = "conversation_subscribed" + MessageTypeTyping = "typing" ) // WSMessage represents a WS message. @@ -26,3 +29,15 @@ type BroadcastMessage struct { Data []byte `json:"data"` Users []int `json:"users"` } + +// ConversationSubscribe represents a conversation subscription message. +type ConversationSubscribe struct { + ConversationUUID string `json:"conversation_uuid"` +} + +// TypingMessage represents a typing indicator message. +type TypingMessage struct { + ConversationUUID string `json:"conversation_uuid"` + IsTyping bool `json:"is_typing"` + UserID int `json:"user_id"` +} diff --git a/internal/ws/ws.go b/internal/ws/ws.go index 36d8bcd4..f1a3d6fc 100644 --- a/internal/ws/ws.go +++ b/internal/ws/ws.go @@ -2,6 +2,7 @@ package ws import ( + "encoding/json" "sync" "github.com/abhinavxd/libredesk/internal/ws/models" @@ -14,22 +15,40 @@ type Hub struct { clients map[int][]*Client clientsMutex sync.RWMutex - userStore userStore + // Conversation UUID to clients map for faster conversation broadcasting + conversationClients map[string][]*Client + conversationClientsMutex sync.RWMutex + + userStore userStore + conversationStore conversationStore } type userStore interface { UpdateLastActive(userID int) error } +type conversationStore interface { + BroadcastTypingToWidgetClientsOnly(conversationUUID string, isTyping bool) +} + // NewHub creates a new websocket hub. func NewHub(userStore userStore) *Hub { return &Hub{ - clients: make(map[int][]*Client, 10000), - clientsMutex: sync.RWMutex{}, - userStore: userStore, + clients: make(map[int][]*Client, 10000), + clientsMutex: sync.RWMutex{}, + conversationClients: make(map[string][]*Client), + conversationClientsMutex: sync.RWMutex{}, + userStore: userStore, + // To be set later via conversationStore. + conversationStore: nil, } } +// SetConversationStore sets the conversation store for cross-broadcasting. +func (h *Hub) SetConversationStore(manager conversationStore) { + h.conversationStore = manager +} + // AddClient adds a new client to the hub. func (h *Hub) AddClient(client *Client) { h.clientsMutex.Lock() @@ -41,6 +60,12 @@ func (h *Hub) AddClient(client *Client) { func (h *Hub) RemoveClient(client *Client) { h.clientsMutex.Lock() defer h.clientsMutex.Unlock() + + // Remove from all conversation subscriptions + h.conversationClientsMutex.Lock() + h.removeClientFromAllConversations(client) + h.conversationClientsMutex.Unlock() + if clients, ok := h.clients[client.ID]; ok { for i, c := range clients { if c == client { @@ -74,3 +99,99 @@ func (h *Hub) BroadcastMessage(msg models.BroadcastMessage) { } } } + +// SubscribeToConversation subscribes a client to a conversation. +func (h *Hub) SubscribeToConversation(client *Client, conversationUUID string) { + h.conversationClientsMutex.Lock() + defer h.conversationClientsMutex.Unlock() + + // Unsubscribe from previous conversation if any + h.removeClientFromAllConversations(client) + + // Subscribe to new conversation + h.conversationClients[conversationUUID] = append(h.conversationClients[conversationUUID], client) +} + +// UnsubscribeFromConversation unsubscribes a client from a conversation. +func (h *Hub) UnsubscribeFromConversation(client *Client, conversationUUID string) { + h.conversationClientsMutex.Lock() + defer h.conversationClientsMutex.Unlock() + h.unsubscribeFromConversationUnsafe(client, conversationUUID) +} + +// unsubscribeFromConversationUnsafe removes a client from conversation subscription without locking. +// Must be called with conversationClientsMutex held. +func (h *Hub) unsubscribeFromConversationUnsafe(client *Client, conversationUUID string) { + if clients, ok := h.conversationClients[conversationUUID]; ok { + for i, c := range clients { + if c == client { + h.conversationClients[conversationUUID] = append(clients[:i], clients[i+1:]...) + if len(h.conversationClients[conversationUUID]) == 0 { + delete(h.conversationClients, conversationUUID) + } + break + } + } + } +} + +// removeClientFromAllConversations removes a client from all conversation subscriptions. +// Must be called with conversationClientsMutex held. +func (h *Hub) removeClientFromAllConversations(client *Client) { + for conversationUUID, clients := range h.conversationClients { + for i, c := range clients { + if c == client { + h.conversationClients[conversationUUID] = append(clients[:i], clients[i+1:]...) + if len(h.conversationClients[conversationUUID]) == 0 { + delete(h.conversationClients, conversationUUID) + } + break + } + } + } +} + +// BroadcastToConversation broadcasts a message to all clients subscribed to a specific conversation. +func (h *Hub) BroadcastToConversation(conversationUUID string, data []byte) { + h.conversationClientsMutex.RLock() + defer h.conversationClientsMutex.RUnlock() + + for _, client := range h.conversationClients[conversationUUID] { + client.SendMessage(data, websocket.TextMessage) + } +} + +// BroadcastTypingToConversation broadcasts typing status to all clients subscribed to a conversation except the sender. +func (h *Hub) BroadcastTypingToConversation(conversationUUID string, typingMsg models.TypingMessage, sender *Client) { + h.conversationClientsMutex.RLock() + defer h.conversationClientsMutex.RUnlock() + + message := models.Message{ + Type: models.MessageTypeTyping, + Data: typingMsg, + } + + messageBytes, _ := json.Marshal(message) + + for _, client := range h.conversationClients[conversationUUID] { + // Don't send typing indicator back to the sender. + if client != sender { + client.SendMessage(messageBytes, websocket.TextMessage) + } + } + + // Also broadcast to widget clients since this is an agent typing. + if h.conversationStore != nil { + h.conversationStore.BroadcastTypingToWidgetClientsOnly(conversationUUID, typingMsg.IsTyping) + } +} + +// BroadcastTypingToAllConversationClients broadcasts typing status to all clients subscribed to a conversation. +func (h *Hub) BroadcastTypingToAllConversationClients(conversationUUID string, data []byte) { + h.conversationClientsMutex.RLock() + defer h.conversationClientsMutex.RUnlock() + + for _, client := range h.conversationClients[conversationUUID] { + client.SendMessage(data, websocket.TextMessage) + } +} diff --git a/schema.sql b/schema.sql index bfa6b7a9..0c821f4e 100644 --- a/schema.sql +++ b/schema.sql @@ -151,8 +151,12 @@ CREATE TABLE users ( CONSTRAINT constraint_users_on_first_name CHECK (LENGTH(first_name) <= 140), CONSTRAINT constraint_users_on_last_name CHECK (LENGTH(last_name) <= 140) ); -CREATE UNIQUE INDEX index_unique_users_on_email_and_type_when_deleted_at_is_null ON users (email, type) -WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX index_unique_users_on_email_when_type_is_contact + ON users(email) + WHERE type = 'contact' AND deleted_at IS NULL; +CREATE UNIQUE INDEX index_unique_users_on_email_when_type_is_agent + ON users(email) + WHERE type = 'agent' AND deleted_at IS NULL; CREATE INDEX index_tgrm_users_on_email ON users USING GIN (email gin_trgm_ops); CREATE INDEX index_users_on_api_key ON users(api_key); diff --git a/static/widget.js b/static/widget.js index b6524743..76eea84f 100644 --- a/static/widget.js +++ b/static/widget.js @@ -6,11 +6,11 @@ 'use strict'; // Prevent multiple initializations - if (window.LibreDeskWidget) { + if (window.LibredeskWidget) { return; } - class LibreDeskWidget { + class LibredeskWidget { constructor(config = {}) { // Validate required config if (!config.baseUrl) { @@ -23,9 +23,12 @@ this.config = config; this.iframe = null; this.toggleButton = null; + this.widgetButtonWrapper = null; + this.unreadBadge = null; this.isChatVisible = false; this.widgetSettings = null; - + this.unreadCount = 0; + this.isMobile = window.innerWidth <= 600; this.init(); } @@ -33,18 +36,25 @@ try { await this.fetchWidgetSettings(); this.createElements(); + this.setLauncherPosition(); + this.iframe.addEventListener('load', () => { + setTimeout(() => { + this.sendMobileState(); + }, 2000); + }); + this.setupMobileDetection(); this.setupEventListeners(); } catch (error) { - console.error('Failed to initialize LibreDesk Widget:', error); + console.error('Failed to initialize Libredesk Widget:', error); } } async fetchWidgetSettings () { try { - const response = await fetch(`${this.config.baseUrl}/api/v1/widget/chat/settings?inbox_id=${this.config.inboxID}`); + const response = await fetch(`${this.config.baseUrl}/api/v1/widget/chat/settings/launcher?inbox_id=${this.config.inboxID}`); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + throw new Error(`Error fetching widget settings. Status: ${response.status}`); } const result = await response.json(); @@ -94,6 +104,39 @@ this.toggleButton.appendChild(icon); } + // Create unread badge + this.unreadBadge = document.createElement('div'); + this.unreadBadge.style.cssText = ` + position: absolute; + top: -5px; + right: -5px; + background-color: #ef4444; + color: white; + border-radius: 50%; + width: 20px; + height: 20px; + display: none; + justify-content: center; + align-items: center; + font-size: 12px; + font-weight: bold; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + border: 2px solid white; + box-sizing: border-box; + z-index: 10000; + `; + + const widgetButtonWrapper = document.createElement('div'); + widgetButtonWrapper.style.cssText = ` + position: fixed; + z-index: 9999; + `; + + widgetButtonWrapper.appendChild(this.toggleButton); + widgetButtonWrapper.appendChild(this.unreadBadge); + this.toggleButton.style.position = 'relative'; + this.widgetButtonWrapper = widgetButtonWrapper; + // Create iframe this.iframe = document.createElement('iframe'); this.iframe.src = `${this.config.baseUrl}/widget/?inbox_id=${this.config.inboxID}`; @@ -109,9 +152,19 @@ display: none; `; - document.body.appendChild(this.toggleButton); + document.body.appendChild(this.widgetButtonWrapper); document.body.appendChild(this.iframe); - this.setLauncherPosition(); + } + + sendMobileState () { + this.isMobile = window.innerWidth <= 600; + // Send message to iframe to update mobile state there. + if (this.iframe && this.iframe.contentWindow) { + this.iframe.contentWindow.postMessage({ + type: 'SET_MOBILE_STATE', + isMobile: this.isMobile + }, '*'); + } } setLauncherPosition () { @@ -120,9 +173,9 @@ const position = launcher.position; const side = position === 'right' ? 'right' : 'left'; - // Position toggle button - this.toggleButton.style.bottom = `${spacing.bottom}px`; - this.toggleButton.style[side] = `${spacing.side}px`; + // Position button wrapper (which contains the toggle button and badge) + this.widgetButtonWrapper.style.bottom = `${spacing.bottom}px`; + this.widgetButtonWrapper.style[side] = `${spacing.side}px`; // Position iframe this.iframe.style.bottom = `${spacing.bottom + 80}px`; @@ -131,6 +184,33 @@ setupEventListeners () { this.toggleButton.addEventListener('click', () => this.toggle()); + + // Listen for messages from the iframe (Vue widget app) + window.addEventListener('message', (event) => { + // Verify the message is from our iframe. + if (event.source === this.iframe.contentWindow) { + if (event.data.type === 'CLOSE_WIDGET') { + this.hideChat(); + } else if (event.data.type === 'UPDATE_UNREAD_COUNT') { + this.updateUnreadCount(event.data.count); + } + } + }); + } + + setupMobileDetection () { + window.addEventListener('resize', () => { + this.sendMobileState(); + if (this.isChatVisible) { + this.showChat(); + } + }); + window.addEventListener('orientationchange', () => { + this.sendMobileState(); + if (this.isChatVisible) { + this.showChat(); + } + }); } toggle () { @@ -143,9 +223,36 @@ showChat () { if (this.iframe) { - this.iframe.style.display = 'block'; + this.isMobile = window.innerWidth <= 600; + if (this.isMobile) { + this.iframe.style.display = 'block'; + this.iframe.style.position = 'fixed'; + this.iframe.style.top = '0'; + this.iframe.style.left = '0'; + this.iframe.style.width = '100vw'; + this.iframe.style.height = '100vh'; + this.iframe.style.borderRadius = '0'; + this.iframe.style.boxShadow = 'none'; + this.iframe.style.bottom = ''; + this.iframe.style.right = ''; + this.iframe.style.left = ''; + this.iframe.style.top = '0'; + this.widgetButtonWrapper.style.display = 'none'; + } else { + this.iframe.style.display = 'block'; + this.iframe.style.position = 'fixed'; + this.iframe.style.width = '400px'; + this.iframe.style.height = '700px'; + this.iframe.style.borderRadius = '10px'; + this.iframe.style.boxShadow = '0 4px 20px rgba(0,0,0,0.25)'; + this.iframe.style.top = ''; + this.iframe.style.left = ''; + this.setLauncherPosition(); + this.widgetButtonWrapper.style.display = ''; + } this.isChatVisible = true; this.toggleButton.style.transform = 'scale(0.9)'; + this.unreadBadge.style.display = 'none'; } } @@ -154,13 +261,27 @@ this.iframe.style.display = 'none'; this.isChatVisible = false; this.toggleButton.style.transform = 'scale(1)'; + this.widgetButtonWrapper.style.display = ''; + } + } + + updateUnreadCount (count) { + this.unreadCount = count; + + if (count > 0 && !this.isChatVisible) { + this.unreadBadge.textContent = count > 99 ? '99+' : count.toString(); + this.unreadBadge.style.display = 'flex'; + } else { + this.unreadBadge.style.display = 'none'; } } destroy () { - if (this.toggleButton) { - document.body.removeChild(this.toggleButton); + if (this.widgetButtonWrapper) { + document.body.removeChild(this.widgetButtonWrapper); + this.widgetButtonWrapper = null; this.toggleButton = null; + this.unreadBadge = null; } if (this.iframe) { document.body.removeChild(this.iframe); @@ -171,19 +292,19 @@ } // Global widget instance - window.LibreDeskWidget = LibreDeskWidget; + window.LibredeskWidget = LibredeskWidget; // Auto-initialize if configuration is provided - if (window.libreDeskConfig) { - window.libreDeskWidget = new LibreDeskWidget(window.libreDeskConfig); + if (window.LibredeskConfig) { + window.libreDeskWidget = new LibredeskWidget(window.LibredeskConfig); } window.initLibreDeskWidget = function (config = {}) { if (window.libreDeskWidget) { - console.warn('LibreDesk Widget is already initialized'); + console.warn('Libredesk Widget is already initialized'); return window.libreDeskWidget; } - window.libreDeskWidget = new LibreDeskWidget(config); + window.libreDeskWidget = new LibredeskWidget(config); return window.libreDeskWidget; };