diff --git a/Makefile b/Makefile index 9efc3696..1ff1eb4d 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ GOPATH ?= $(HOME)/go STUFFBIN ?= $(GOPATH)/bin/stuffbin # The default target to run when `make` is executed. -.DEFAULT_GOAL := build +.DEFAULT_GOAL := build # Install stuffbin if it doesn't exist. $(STUFFBIN): @@ -28,11 +28,24 @@ install-deps: $(STUFFBIN) @echo "→ Installing frontend dependencies..." @cd ${FRONTEND_DIR} && pnpm install -# Build the frontend for production. +# Build the frontend for production (both apps). .PHONY: frontend-build frontend-build: install-deps - @echo "→ Building frontend for production..." - @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build + @echo "→ Building frontend for production - main app & widget..." + @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main + @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget + +# Build only the main frontend app. +.PHONY: frontend-build-main +frontend-build-main: install-deps + @echo "→ Building main frontend app for production..." + @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main + +# Build only the widget frontend app. +.PHONY: frontend-build-widget +frontend-build-widget: install-deps + @echo "→ Building widget frontend app for production..." + @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget # Run the Go backend server in development mode. .PHONY: run-backend @@ -40,13 +53,29 @@ run-backend: @echo "→ Running backend..." CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go -# Run the JS frontend server in development mode. +# Run the JS frontend server in development mode (main app only). .PHONY: run-frontend run-frontend: @echo "→ Installing frontend dependencies (if not already installed)..." @cd ${FRONTEND_DIR} && pnpm install - @echo "→ Running frontend..." - @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev + @echo "→ Running main frontend app..." + @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:main + +# Run the main frontend app in development mode. +.PHONY: run-frontend-main +run-frontend-main: + @echo "→ Installing frontend dependencies (if not already installed)..." + @cd ${FRONTEND_DIR} && pnpm install + @echo "→ Running main frontend app..." + @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:main + +# Run the widget frontend app in development mode. +.PHONY: run-frontend-widget +run-frontend-widget: + @echo "→ Installing frontend dependencies (if not already installed)..." + @cd ${FRONTEND_DIR} && pnpm install + @echo "→ Running widget frontend app..." + @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:widget # Build the backend binary. .PHONY: build-backend diff --git a/cmd/chat.go b/cmd/chat.go new file mode 100644 index 00000000..2f412d37 --- /dev/null +++ b/cmd/chat.go @@ -0,0 +1,1137 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "math" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + + "github.com/abhinavxd/libredesk/internal/attachment" + 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/inbox/channel/livechat" + imodels "github.com/abhinavxd/libredesk/internal/inbox/models" + "github.com/abhinavxd/libredesk/internal/stringutil" + umodels "github.com/abhinavxd/libredesk/internal/user/models" + "github.com/golang-jwt/jwt/v5" + "github.com/valyala/fasthttp" + "github.com/volatiletech/null/v9" + "github.com/zerodha/fastglue" +) + +// Define JWT claims structure +type Claims struct { + UserID int `json:"user_id,omitempty"` + ExternalUserID string `json:"external_user_id,omitempty"` + IsVisitor bool `json:"is_visitor,omitempty"` + Email string `json:"email,omitempty"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + CustomAttributes map[string]any `json:"custom_attributes,omitempty"` + jwt.RegisteredClaims +} + +type conversationResp struct { + Conversation cmodels.ChatConversation `json:"conversation"` + Messages []cmodels.ChatMessage `json:"messages"` +} + +type customAttributeWidget struct { + ID int `json:"id"` + Values []string `json:"values"` +} + +type chatInitReq struct { + Message string `json:"message"` + FormData map[string]any `json:"form_data"` +} + +type chatSettingsResponse struct { + livechat.Config + BusinessHours []bhmodels.BusinessHours `json:"business_hours,omitempty"` + DefaultBusinessHoursID int `json:"default_business_hours_id,omitempty"` + CustomAttributes map[int]customAttributeWidget `json:"custom_attributes,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"` +} + +// validateLiveChatInbox validates inbox_id from query params and returns the inbox and parsed config. +// Used by public widget endpoints that don't require JWT authentication. +func validateLiveChatInbox(r *fastglue.Request) (imodels.Inbox, livechat.Config, error) { + app := r.Context.(*App) + inboxID := r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id") + + if inboxID <= 0 { + return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope( + fasthttp.StatusBadRequest, + app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), + nil, envelope.InputError) + } + + inbox, err := app.inbox.GetDBRecord(inboxID) + if err != nil { + app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err) + return imodels.Inbox{}, livechat.Config{}, sendErrorEnvelope(r, err) + } + + if inbox.Channel != livechat.ChannelLiveChat { + return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope( + fasthttp.StatusBadRequest, + app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), + nil, envelope.InputError) + } + + if !inbox.Enabled { + return imodels.Inbox{}, livechat.Config{}, 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 imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope( + fasthttp.StatusInternalServerError, + app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), + nil, envelope.GeneralError) + } + + return inbox, config, nil +} + +// handleGetChatLauncherSettings returns the live chat launcher settings for the widget +func handleGetChatLauncherSettings(r *fastglue.Request) error { + _, config, err := validateLiveChatInbox(r) + if err != nil { + return err + } + + 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 { + app := r.Context.(*App) + + _, config, err := validateLiveChatInbox(r) + if err != nil { + return err + } + + // Get business hours data if office hours feature is enabled. + response := chatSettingsResponse{ + Config: config, + } + + if config.ShowOfficeHoursInChat { + 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) + } + } + } + } + + // Filter out pre-chat form fields for which custom attributes don't exist anymore. + if config.PreChatForm.Enabled && len(config.PreChatForm.Fields) > 0 { + filteredFields, customAttributes := filterPreChatFormFields(config.PreChatForm.Fields, app) + response.PreChatForm.Fields = filteredFields + if len(customAttributes) > 0 { + response.CustomAttributes = customAttributes + } + } + + return r.SendEnvelope(response) +} + +// handleChatInit initializes a new chat session. +func handleChatInit(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + req = chatInitReq{} + ) + + if err := r.Decode(&req, "json"); err != nil { + app.lo.Error("error unmarshalling chat init request", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) + } + + if req.Message == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.message}"), nil, envelope.InputError) + } + + // Get authenticated data from context (set by middleware), middleware always validates inbox, so we can safely use non-optional getters + claims := getWidgetClaimsOptional(r) + inboxID, err := getWidgetInboxID(r) + if err != nil { + app.lo.Error("error getting inbox ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) + } + inbox, err := getWidgetInbox(r) + if err != nil { + app.lo.Error("error getting inbox from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) + } + + var ( + contactID int + conversationUUID string + isVisitor bool + config livechat.Config + newJWT string + ) + + // Parse inbox 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) + } + + // Handle authenticated user vs visitor + if claims != nil { + // Handle existing contacts with external user id - check if we need to create user + if claims.ExternalUserID != "" { + // Find or create user based on external_user_id. + user, err := app.user.GetByExternalID(claims.ExternalUserID) + if err != nil { + envErr, ok := err.(envelope.Error) + if ok && envErr.ErrorType != envelope.NotFoundError { + app.lo.Error("error fetching user by external ID", "external_user_id", claims.ExternalUserID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + + // User doesn't exist, create new contact + firstName := claims.FirstName + lastName := claims.LastName + email := claims.Email + + // Validate custom attribute + formCustomAttributes := validateCustomAttributes(req.FormData, config, app) + + // Merge JWT and form custom attributes (form takes precedence) + mergedAttributes := mergeCustomAttributes(claims.CustomAttributes, formCustomAttributes) + + // Marshal custom attributes + customAttribJSON, err := json.Marshal(mergedAttributes) + if err != nil { + app.lo.Error("error marshalling custom attributes", "error", err) + customAttribJSON = []byte("{}") + } + + // Create new contact with external user ID. + var user = umodels.User{ + FirstName: firstName, + LastName: lastName, + Email: null.NewString(email, email != ""), + ExternalUserID: null.NewString(claims.ExternalUserID, claims.ExternalUserID != ""), + CustomAttributes: customAttribJSON, + } + err = app.user.CreateContact(&user) + if err != nil { + app.lo.Error("error creating contact with external ID", "external_user_id", claims.ExternalUserID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + contactID = user.ID + } else { + // User exists, update custom attributes from both JWT and form + // Don't override existing name and email. + + // Validate custom attribute + formCustomAttributes := validateCustomAttributes(req.FormData, config, app) + + // Merge JWT and form custom attributes (form takes precedence) + mergedAttributes := mergeCustomAttributes(claims.CustomAttributes, formCustomAttributes) + + if len(mergedAttributes) > 0 { + if err := app.user.SaveCustomAttributes(user.ID, mergedAttributes, false); err != nil { + app.lo.Error("error updating contact custom attributes", "contact_id", user.ID, "error", err) + // Don't fail the request for custom attributes update failure + } + } + contactID = user.ID + } + isVisitor = false + } else { + // Authenticated visitor + isVisitor = claims.IsVisitor + contactID, err = getWidgetContactID(r) + if err != nil { + app.lo.Error("error getting contact ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + + // Validate custom attribute + formCustomAttributes := validateCustomAttributes(req.FormData, config, app) + + // Merge JWT and form custom attributes (form takes precedence) + mergedAttributes := mergeCustomAttributes(claims.CustomAttributes, formCustomAttributes) + + // Update custom attributes from both JWT and form + if len(mergedAttributes) > 0 { + if err := app.user.SaveCustomAttributes(contactID, mergedAttributes, false); err != nil { + app.lo.Error("error updating contact custom attributes", "contact_id", contactID, "error", err) + // Don't fail the request for custom attributes update failure + } + } + } + } else { + // Visitor user not authenticated, create a new visitor contact. + isVisitor = true + + // Validate form data and get final name/email for new visitor + finalName, finalEmail, err := validateFormData(req.FormData, config, nil) + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, err.Error(), nil, envelope.InputError) + } + + // Process custom attributes from form data + formCustomAttributes := validateCustomAttributes(req.FormData, config, app) + + // Marshal custom attributes for storage + var customAttribJSON []byte + if len(formCustomAttributes) > 0 { + customAttribJSON, err = json.Marshal(formCustomAttributes) + if err != nil { + app.lo.Error("error marshalling form custom attributes", "error", err) + customAttribJSON = []byte("{}") + } + } else { + customAttribJSON = []byte("{}") + } + + visitor := umodels.User{ + Email: null.NewString(finalEmail, finalEmail != ""), + FirstName: finalName, + CustomAttributes: customAttribJSON, + } + + if err := app.user.CreateVisitor(&visitor); err != nil { + app.lo.Error("error creating visitor contact", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + contactID = visitor.ID + secretToUse := []byte(inbox.Secret.String) + newJWT, err = generateUserJWTWithSecret(contactID, isVisitor, time.Now().Add(87600*time.Hour), secretToUse) // 10 years + if err != nil { + app.lo.Error("error generating visitor JWT", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorGenerating", "name", "{globals.terms.session}"), nil, envelope.GeneralError) + } + } + + // Check conversation permissions based on user type. + var allowStartConversation, preventMultipleConversations bool + if isVisitor { + allowStartConversation = config.Visitors.AllowStartConversation + preventMultipleConversations = config.Visitors.PreventMultipleConversations + } else { + allowStartConversation = config.Users.AllowStartConversation + preventMultipleConversations = config.Users.PreventMultipleConversations + } + + if !allowStartConversation { + return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.notAllowed", "name", ""), nil, envelope.PermissionError) + } + + if preventMultipleConversations { + conversations, err := app.conversation.GetContactChatConversations(contactID, inboxID) + 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.Ts("globals.messages.notAllowed", "name", ""), nil, envelope.PermissionError) + } + } + + app.lo.Info("creating new live chat conversation for user", "user_id", contactID, "inbox_id", inboxID, "is_visitor", isVisitor) + + // Create conversation. + _, conversationUUID, err = app.conversation.CreateConversation( + contactID, + inboxID, + "", + time.Now(), + "", + false, + ) + if err != nil { + app.lo.Error("error creating conversation", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError) + } + + // Insert initial message. + message := cmodels.Message{ + ConversationUUID: conversationUUID, + SenderID: contactID, + Type: cmodels.MessageIncoming, + SenderType: cmodels.SenderTypeContact, + Status: cmodels.MessageStatusReceived, + Content: req.Message, + ContentType: cmodels.ContentTypeText, + Private: false, + } + if err := app.conversation.InsertMessage(&message); err != nil { + // Clean up conversation if message insert fails. + if err := app.conversation.DeleteConversation(conversationUUID); err != nil { + app.lo.Error("error deleting conversation after message insert failure", "conversation_uuid", conversationUUID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError) + } + app.lo.Error("error inserting initial message", "conversation_uuid", conversationUUID, "error", err) + 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) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil, envelope.GeneralError) + } + + // Build response with conversation and messages and add business hours info. + resp, err := buildConversationResponseWithBusinessHours(app, conversation) + if err != nil { + return sendErrorEnvelope(r, err) + } + + // For visitors, return the new JWT. For authenticated users, no JWT is needed in response. + response := map[string]any{ + "conversation": resp.Conversation, + "messages": resp.Messages, + "business_hours_id": resp.BusinessHoursID, + "working_hours_utc_offset": resp.WorkingHoursUTCOffset, + } + + // Only add JWT for visitor creation + if newJWT != "" { + response["jwt"] = newJWT + } + + return r.SendEnvelope(response) +} + +// handleChatUpdateLastSeen updates contact last seen timestamp for a conversation +func handleChatUpdateLastSeen(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + conversationUUID = r.RequestCtx.UserValue("uuid").(string) + ) + + if conversationUUID == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.conversation}"), nil, envelope.InputError) + } + + // Get authenticated data from middleware context + contactID, err := getWidgetContactID(r) + if err != nil { + app.lo.Error("error getting contact ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + + 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 contact. + if conversation.ContactID != contactID { + app.lo.Error("unauthorized access to conversation", "conversation_uuid", conversationUUID, "contact_id", contactID, "conversation_contact_id", conversation.ContactID) + return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError) + } + + // Update last seen timestamp. + if err := app.conversation.UpdateConversationContactLastSeen(conversation.UUID); err != nil { + app.lo.Error("error updating contact last seen timestamp", "conversation_uuid", conversationUUID, "error", err) + return sendErrorEnvelope(r, err) + } + + // Also update custom attributes from JWT claims, if present. + // This avoids a separate handler and ensures contact attributes stay in sync. + // Since this endpoint is hit frequently during chat, it's a good place to keep them updated. + claims := getWidgetClaimsOptional(r) + if claims != nil && len(claims.CustomAttributes) > 0 { + if err := app.user.SaveCustomAttributes(contactID, claims.CustomAttributes, false); err != nil { + app.lo.Error("error updating contact custom attributes", "contact_id", contactID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + } + + return r.SendEnvelope(true) +} + +// handleChatGetConversation fetches a chat conversation by ID +func handleChatGetConversation(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + conversationUUID = r.RequestCtx.UserValue("uuid").(string) + ) + + if conversationUUID == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "conversation_id is required", nil, envelope.InputError) + } + + // Get authenticated data from middleware context + contactID, err := getWidgetContactID(r) + if err != nil { + app.lo.Error("error getting contact ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + + // Fetch conversation + 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 contact. + if conversation.ContactID != contactID { + app.lo.Error("unauthorized access to conversation", "conversation_uuid", conversationUUID, "contact_id", contactID, "conversation_contact_id", conversation.ContactID) + return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError) + } + + // Build conversation response with messages and attachments. + resp, err := buildConversationResponseWithBusinessHours(app, conversation) + if err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(resp) +} + +// handleGetConversations fetches all chat conversations for a widget user +func handleGetConversations(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + ) + + // Get authenticated data from middleware context + contactID, err := getWidgetContactID(r) + if err != nil { + app.lo.Error("error getting contact ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + + inboxID, err := getWidgetInboxID(r) + if err != nil { + app.lo.Error("error getting inbox ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) + } + + // Fetch conversations for the contact and convert to ChatConversation format. + chatConversations, err := app.conversation.GetContactChatConversations(contactID, inboxID) + if err != nil { + app.lo.Error("error fetching conversations for contact", "contact_id", contactID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil, envelope.GeneralError) + } + + return r.SendEnvelope(chatConversations) +} + +// handleChatSendMessage sends a message in a chat conversation +func handleChatSendMessage(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + conversationUUID = r.RequestCtx.UserValue("uuid").(string) + req = struct { + Message string `json:"message"` + }{} + senderType = cmodels.SenderTypeContact + ) + + if err := r.Decode(&req, "json"); err != nil { + app.lo.Error("error unmarshalling chat message request", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) + } + + if req.Message == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.message}"), nil, envelope.InputError) + } + + // Get authenticated data from middleware context + senderID, err := getWidgetContactID(r) + if err != nil { + app.lo.Error("error getting contact ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + + inbox, err := getWidgetInbox(r) + if err != nil { + app.lo.Error("error getting inbox from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) + } + + // Fetch conversation to ensure it exists + 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) + } + + // Fetch sender. + sender, err := app.user.Get(senderID, "", []string{}) + 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) + } + + // 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) + } + + // Make sure the inbox is enabled. + if !inbox.Enabled { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + // Insert incoming message and run post processing hooks. + message := cmodels.Message{ + ConversationUUID: conversationUUID, + ConversationID: conversation.ID, + SenderID: senderID, + Type: cmodels.MessageIncoming, + SenderType: senderType, + Status: cmodels.MessageStatusReceived, + Content: req.Message, + ContentType: cmodels.ContentTypeText, + Private: false, + } + 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) + } + + // 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) + ) + + form, err := r.RequestCtx.MultipartForm() + if err != nil { + app.lo.Error("error parsing form data.", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError) + } + + // Get authenticated data from middleware context + senderID, err := getWidgetContactID(r) + if err != nil { + app.lo.Error("error getting contact ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + + inbox, err := getWidgetInbox(r) + if err != nil { + app.lo.Error("error getting inbox from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) + } + + // Get conversation UUID from form data + conversationValues, convOk := form.Value["conversation_uuid"] + if !convOk || len(conversationValues) == 0 || conversationValues[0] == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.conversation}"), nil, envelope.InputError) + } + conversationUUID := conversationValues[0] + + // 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) + } + + 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) + } + + // Make sure file upload is enabled for the inbox. + 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) + } + + if !config.Features.FileUpload { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.fileUpload}"), nil, envelope.InputError) + } + + files, ok := form.File["files"] + if !ok || len(files) == 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.InputError) + } + + fileHeader := files[0] + file, err := fileHeader.Open() + if err != nil { + app.lo.Error("error reading uploaded file", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil, envelope.GeneralError) + } + defer file.Close() + + // Sanitize filename. + srcFileName := stringutil.SanitizeFilename(fileHeader.Filename) + srcContentType := fileHeader.Header.Get("Content-Type") + srcFileSize := fileHeader.Size + srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".") + + // Check file size + consts := app.consts.Load().(*constants) + if bytesToMegabytes(srcFileSize) > float64(consts.MaxFileUploadSizeMB) { + app.lo.Error("error: uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", consts.MaxFileUploadSizeMB) + return r.SendErrorEnvelope( + fasthttp.StatusRequestEntityTooLarge, + app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", consts.MaxFileUploadSizeMB)), + nil, + envelope.GeneralError, + ) + } + + // Make sure the file extension is allowed. + if !slices.Contains(consts.AllowedUploadFileExtensions, "*") && !slices.Contains(consts.AllowedUploadFileExtensions, srcExt) { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("media.fileTypeNotAllowed"), nil, envelope.InputError) + } + + // Read file content into byte slice + file.Seek(0, 0) + 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) + } + + // Get sender user for ProcessIncomingMessage + sender, err := app.user.Get(senderID, "", []string{}) + 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, + ConversationID: conversation.ID, + SenderID: senderID, + Type: cmodels.MessageIncoming, + SenderType: cmodels.SenderTypeContact, + Status: cmodels.MessageStatusReceived, + Content: "", + ContentType: cmodels.ContentTypeText, + Private: false, + Attachments: attachment.Attachments{ + { + Name: srcFileName, + ContentType: srcContentType, + Size: int(srcFileSize), + Content: fileContent, + Disposition: attachment.DispositionAttachment, + }, + }, + } + + // 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 { + 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(insertedMessage) +} + +// buildConversationResponseWithBusinessHours builds conversation response with business hours info +func buildConversationResponseWithBusinessHours(app *App, conversation cmodels.Conversation) (conversationResponseWithBusinessHours, error) { + widgetResp, err := app.conversation.BuildWidgetConversationResponse(conversation, true) + if err != nil { + return conversationResponseWithBusinessHours{}, err + } + + resp := conversationResponseWithBusinessHours{ + conversationResp: conversationResp{ + Conversation: widgetResp.Conversation, + Messages: widgetResp.Messages, + }, + BusinessHoursID: widgetResp.BusinessHoursID, + WorkingHoursUTCOffset: widgetResp.WorkingHoursUTCOffset, + } + + return resp, nil +} + +// resolveUserIDFromClaims resolves the actual user ID from JWT claims, +// handling both regular user_id and external_user_id cases +func resolveUserIDFromClaims(app *App, claims Claims) (int, error) { + if claims.UserID > 0 { + user, err := app.user.Get(claims.UserID, "", []string{}) + if err != nil { + app.lo.Error("error fetching user by user ID", "user_id", claims.UserID, "error", err) + return 0, errors.New("error fetching user") + } + if !user.Enabled { + return 0, errors.New("user is disabled") + } + return user.ID, nil + } else if claims.ExternalUserID != "" { + user, err := app.user.GetByExternalID(claims.ExternalUserID) + if err != nil { + app.lo.Error("error fetching user by external ID", "external_user_id", claims.ExternalUserID, "error", err) + return 0, errors.New("error fetching user") + } + if !user.Enabled { + return 0, errors.New("user is disabled") + } + + return user.ID, nil + } + + return 0, errors.New("error fetching user") +} + +// verifyJWT verifies and validates a JWT token with proper signature verification +func verifyJWT(tokenString string, secretKey []byte) (*Claims, error) { + // Parse and verify the token + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + // Verify the signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return secretKey, nil + }) + + if err != nil { + return nil, err + } + + // Extract claims if token is valid + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, fmt.Errorf("invalid token") +} + +// verifyStandardJWT verifies a JWT token using inbox secret +func verifyStandardJWT(jwtToken string, inboxSecret string) (Claims, error) { + if jwtToken == "" { + return Claims{}, fmt.Errorf("JWT token is empty") + } + + if inboxSecret == "" { + return Claims{}, fmt.Errorf("inbox `secret` is not configured for JWT verification") + } + + claims, err := verifyJWT(jwtToken, []byte(inboxSecret)) + if err != nil { + return Claims{}, err + } + + return *claims, nil +} + +// generateUserJWTWithSecret generates a JWT token for a user with a specific secret +func generateUserJWTWithSecret(userID int, isVisitor bool, expirationTime time.Time, secret []byte) (string, error) { + claims := &Claims{ + UserID: userID, + IsVisitor: isVisitor, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(secret) + if err != nil { + return "", err + } + return tokenString, nil +} + +// mergeCustomAttributes merges JWT and form custom attributes with form taking precedence +func mergeCustomAttributes(jwtAttributes, formAttributes map[string]interface{}) map[string]interface{} { + merged := make(map[string]interface{}) + + // Add JWT attributes first (as fallback) + maps.Copy(merged, jwtAttributes) + + // Add form attributes second (takes precedence) + maps.Copy(merged, formAttributes) + + return merged +} + +// validateCustomAttributes validates and processes custom attributes from form data +func validateCustomAttributes(formData map[string]interface{}, config livechat.Config, app *App) map[string]interface{} { + customAttributes := make(map[string]interface{}) + + if !config.PreChatForm.Enabled || len(formData) == 0 { + return customAttributes + } + + // Validate total number of form fields + const maxFormFields = 50 + if len(formData) > maxFormFields { + app.lo.Warn("form data exceeds maximum allowed fields", "received", len(formData), "max", maxFormFields) + return customAttributes + } + + // Create a map of valid field keys for quick lookup + validFields := make(map[string]livechat.PreChatFormField) + for _, field := range config.PreChatForm.Fields { + if field.Enabled { + validFields[field.Key] = field + } + } + + // Process each form data field + for key, value := range formData { + // Validate field key length + const maxKeyLength = 100 + if len(key) > maxKeyLength { + app.lo.Warn("form field key exceeds maximum length", "key", key, "length", len(key), "max", maxKeyLength) + continue + } + + // Check if field is valid according to pre-chat form config + field, exists := validFields[key] + if !exists { + app.lo.Warn("form field not found in pre-chat form configuration", "key", key) + continue + } + + // Skip default fields (name, email) - these are handled separately + if field.IsDefault { + continue + } + + // Only process custom fields that have a custom_attribute_id + if field.CustomAttributeID == 0 { + continue + } + + // Validate and process string values with length limits + if strValue, ok := value.(string); ok { + const maxValueLength = 1000 + if len(strValue) > maxValueLength { + app.lo.Warn("form field value exceeds maximum length", "key", key, "length", len(strValue), "max", maxValueLength) + // Truncate the value instead of rejecting it + strValue = strValue[:maxValueLength] + } + customAttributes[field.Key] = strValue + } + + // Numbers + if numValue, ok := value.(float64); ok { + if math.IsNaN(numValue) || math.IsInf(numValue, 0) { + app.lo.Warn("form field contains invalid numeric value", "key", key, "value", numValue) + continue + } + + if numValue > 1e12 || numValue < -1e12 { + app.lo.Warn("form field numeric value out of acceptable range", "key", key, "value", numValue) + continue + } + + customAttributes[field.Key] = numValue + } + + // Set rest as is + customAttributes[field.Key] = value + } + + return customAttributes +} + +// validateFormData validates form data against pre-chat form configuration +// Returns the final name/email to use and any validation errors +func validateFormData(formData map[string]interface{}, config livechat.Config, existingUser *umodels.User) (string, string, error) { + var finalName, finalEmail string + + if !config.PreChatForm.Enabled { + return finalName, finalEmail, nil + } + + // Process each enabled field in the pre-chat form + for _, field := range config.PreChatForm.Fields { + if !field.Enabled { + continue + } + + switch field.Key { + case "name": + if value, exists := formData[field.Key]; exists { + if nameStr, ok := value.(string); ok { + // For existing users, ignore form name if they already have one + if existingUser != nil && existingUser.FirstName != "" { + finalName = existingUser.FirstName + } else { + finalName = nameStr + } + } + } + // Validate required field + if field.Required && finalName == "" { + return "", "", fmt.Errorf("name is required") + } + + case "email": + if value, exists := formData[field.Key]; exists { + if emailStr, ok := value.(string); ok { + // For existing users, ignore form email if they already have one + if existingUser != nil && existingUser.Email.Valid && existingUser.Email.String != "" { + finalEmail = existingUser.Email.String + } else { + finalEmail = emailStr + } + } + } + // Validate required field + if field.Required && finalEmail == "" { + return "", "", fmt.Errorf("email is required") + } + // Validate email format if provided + if finalEmail != "" && !stringutil.ValidEmail(finalEmail) { + return "", "", fmt.Errorf("invalid email format") + } + } + } + + return finalName, finalEmail, nil +} + +// filterPreChatFormFields filters out pre-chat form fields that reference non-existent custom attributes while retaining the default fields +func filterPreChatFormFields(fields []livechat.PreChatFormField, app *App) ([]livechat.PreChatFormField, map[int]customAttributeWidget) { + if len(fields) == 0 { + return fields, nil + } + + // Collect custom attribute IDs and enabled fields + customAttrIDs := make(map[int]bool) + enabledFields := make([]livechat.PreChatFormField, 0, len(fields)) + + for _, field := range fields { + if field.Enabled { + enabledFields = append(enabledFields, field) + if field.CustomAttributeID > 0 { + customAttrIDs[field.CustomAttributeID] = true + } + } + } + + // Fetch existing custom attributes + existingCustomAttrs := make(map[int]customAttributeWidget) + for id := range customAttrIDs { + attr, err := app.customAttribute.Get(id) + if err != nil { + app.lo.Warn("custom attribute referenced in pre-chat form no longer exists", "custom_attribute_id", id, "error", err) + continue + } + existingCustomAttrs[id] = customAttributeWidget{ + ID: attr.ID, + Values: attr.Values, + } + } + + // Filter out fields with non-existent custom attributes + filteredFields := make([]livechat.PreChatFormField, 0, len(enabledFields)) + for _, field := range enabledFields { + // Keep default fields + if field.IsDefault { + filteredFields = append(filteredFields, field) + continue + } + + // Only keep custom fields if their custom attribute exists + if _, exists := existingCustomAttrs[field.CustomAttributeID]; exists { + filteredFields = append(filteredFields, field) + } + } + + return filteredFields, existingCustomAttrs +} diff --git a/cmd/contacts.go b/cmd/contacts.go index f08b43fe..281f9df4 100644 --- a/cmd/contacts.go +++ b/cmd/contacts.go @@ -275,11 +275,16 @@ func handleBlockContact(r *fastglue.Request) error { app.lo.Info("setting contact block status", "contact_id", contactID, "enabled", req.Enabled, "actor_id", auser.ID) - if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil { + contact, err := app.user.GetContact(contactID, "") + if err != nil { return sendErrorEnvelope(r, err) } - contact, err := app.user.GetContact(contactID, "") + if err := app.user.ToggleEnabled(contactID, contact.Type, req.Enabled); err != nil { + return sendErrorEnvelope(r, err) + } + + contact, err = app.user.GetContact(contactID, "") if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/conversation.go b/cmd/conversation.go index bcfa3612..8478a130 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -544,11 +544,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) @@ -653,7 +648,7 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } - if err := app.user.UpdateCustomAttributes(conversation.ContactID, attributes); err != nil { + if err := app.user.SaveCustomAttributes(conversation.ContactID, attributes, false); err != nil { return sendErrorEnvelope(r, err) } // Broadcast update. @@ -755,11 +750,9 @@ func handleCreateConversation(r *fastglue.Request) error { // Find or create contact. contact := umodels.User{ - Email: null.StringFrom(req.Email), - SourceChannelID: null.StringFrom(req.Email), - FirstName: req.FirstName, - LastName: req.LastName, - InboxID: req.InboxID, + Email: null.StringFrom(req.Email), + FirstName: req.FirstName, + LastName: req.LastName, } if err := app.user.CreateContact(&contact); err != nil { return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil)) @@ -768,7 +761,6 @@ func handleCreateConversation(r *fastglue.Request) error { // Create conversation first. conversationID, conversationUUID, err := app.conversation.CreateConversation( contact.ID, - contact.ContactChannelID, req.InboxID, "", /** last_message **/ time.Now(), /** last_message_at **/ @@ -795,7 +787,7 @@ func handleCreateConversation(r *fastglue.Request) error { switch req.Initiator { case umodels.UserTypeAgent: // Queue reply. - if _, err := app.conversation.QueueReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { + if _, err := app.conversation.QueueReply(media, req.InboxID, auser.ID /**sender_id**/, contact.ID, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { // Delete the conversation if msg queue fails. if err := app.conversation.DeleteConversation(conversationUUID); err != nil { app.lo.Error("error deleting conversation", "error", err) diff --git a/cmd/csat.go b/cmd/csat.go index 0c2e8a7d..7042cfbc 100644 --- a/cmd/csat.go +++ b/cmd/csat.go @@ -3,9 +3,15 @@ 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"` +} const ( maxCsatFeedbackLength = 1000 ) @@ -76,7 +82,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": app.i18n.T("globals.messages.somethingWentWrong"), @@ -112,3 +118,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 17de787f..1b0a012e 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -1,13 +1,17 @@ package main import ( + "encoding/json" "mime" "net/http" "path" "path/filepath" "strconv" + "strings" "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/httputil" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" "github.com/abhinavxd/libredesk/internal/ws" "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" @@ -235,6 +239,9 @@ 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) + // User notifications. g.GET("/api/v1/notifications", auth(handleGetUserNotifications)) g.GET("/api/v1/notifications/stats", auth(handleGetUserNotificationStats)) @@ -248,8 +255,22 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { return handleWS(r, hub) })) + // Live chat widget websocket. + g.GET("/widget/ws", handleWidgetWS) + + // Widget APIs. + g.GET("/api/v1/widget/chat/settings/launcher", handleGetChatLauncherSettings) + g.GET("/api/v1/widget/chat/settings", handleGetChatSettings) + g.POST("/api/v1/widget/chat/conversations/init", rateLimitWidget(widgetAuth(handleChatInit))) + g.GET("/api/v1/widget/chat/conversations", rateLimitWidget(widgetAuth(handleGetConversations))) + g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", rateLimitWidget(widgetAuth(handleChatUpdateLastSeen))) + g.GET("/api/v1/widget/chat/conversations/{uuid}", rateLimitWidget(widgetAuth(handleChatGetConversation))) + g.POST("/api/v1/widget/chat/conversations/{uuid}/message", rateLimitWidget(widgetAuth(handleChatSendMessage))) + g.POST("/api/v1/widget/media/upload", rateLimitWidget(widgetAuth(handleWidgetMediaUpload))) + // Frontend pages. g.GET("/", notAuthPage(serveIndexPage)) + g.GET("/widget", serveWidgetIndexPage) g.GET("/inboxes/{all:*}", authPage(serveIndexPage)) g.GET("/teams/{all:*}", authPage(serveIndexPage)) g.GET("/views/{all:*}", authPage(serveIndexPage)) @@ -259,8 +280,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.GET("/account/{all:*}", authPage(serveIndexPage)) g.GET("/reset-password", notAuthPage(serveIndexPage)) g.GET("/set-password", notAuthPage(serveIndexPage)) - // FIXME: Don't need three separate routes for the same thing. + + // Assets and static files. + // FIXME: Reduce the number of routes. + g.GET("/widget.js", serveWidgetJS) g.GET("/assets/{all:*}", serveFrontendStaticFiles) + g.GET("/widget/assets/{all:*}", serveWidgetStaticFiles) g.GET("/images/{all:*}", serveFrontendStaticFiles) g.GET("/static/public/{all:*}", serveStaticFiles) @@ -297,6 +322,77 @@ func serveIndexPage(r *fastglue.Request) error { return nil } +// validateWidgetReferer validates the Referer header against trusted domains configured in the live chat inbox settings. +func validateWidgetReferer(app *App, r *fastglue.Request, inboxID int) error { + // Get the Referer header from the request + referer := string(r.RequestCtx.Request.Header.Peek("Referer")) + + // If no referer header is present, allow direct access. + if referer == "" { + return nil + } + + // Get inbox configuration + inbox, err := app.inbox.GetDBRecord(inboxID) + if err != nil { + app.lo.Error("error fetching inbox for referer 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 referer 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 referers + if len(config.TrustedDomains) == 0 { + return nil + } + + // Check if the referer matches any of the trusted domains + if !httputil.IsOriginTrusted(referer, config.TrustedDomains) { + app.lo.Warn("widget request from untrusted referer blocked", + "referer", referer, + "inbox_id", inboxID, + "trusted_domains", config.TrustedDomains) + return r.SendErrorEnvelope(http.StatusForbidden, "Widget not allowed from this origin: "+referer, nil, envelope.PermissionError) + } + app.lo.Debug("widget request from trusted referer allowed", "referer", referer, "inbox_id", inboxID) + return nil +} + +// serveWidgetIndexPage serves the widget index page of the application. +func serveWidgetIndexPage(r *fastglue.Request) error { + app := r.Context.(*App) + + // Extract inbox ID and validate trusted domains if present + inboxID := r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id") + if err := validateWidgetReferer(app, r, inboxID); err != nil { + return err + } + + // Prevent caching of the index page. + r.RequestCtx.Response.Header.Add("Cache-Control", "no-store, no-cache, must-revalidate, post-check=0, pre-check=0") + r.RequestCtx.Response.Header.Add("Pragma", "no-cache") + r.RequestCtx.Response.Header.Add("Expires", "-1") + + // Serve the index.html file from the embedded filesystem. + file, err := app.fs.Get(path.Join(widgetDir, "index.html")) + if err != nil { + return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError) + } + r.RequestCtx.Response.Header.Set("Content-Type", "text/html") + r.RequestCtx.SetBody(file.ReadBytes()) + + return nil +} + // serveStaticFiles serves static assets from the embedded filesystem. func serveStaticFiles(r *fastglue.Request) error { app := r.Context.(*App) @@ -345,6 +441,47 @@ func serveFrontendStaticFiles(r *fastglue.Request) error { return nil } +// serveWidgetStaticFiles serves widget static assets from the embedded filesystem. +func serveWidgetStaticFiles(r *fastglue.Request) error { + app := r.Context.(*App) + + filePath := string(r.RequestCtx.Path()) + finalPath := filepath.Join(widgetDir, strings.TrimPrefix(filePath, "/widget")) + + file, err := app.fs.Get(finalPath) + if err != nil { + return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError) + } + + // Set the appropriate Content-Type based on the file extension. + ext := filepath.Ext(filePath) + contentType := mime.TypeByExtension(ext) + if contentType == "" { + contentType = http.DetectContentType(file.ReadBytes()) + } + r.RequestCtx.Response.Header.Set("Content-Type", contentType) + r.RequestCtx.SetBody(file.ReadBytes()) + return nil +} + +// serveWidgetJS serves the widget JavaScript file. +func serveWidgetJS(r *fastglue.Request) error { + app := r.Context.(*App) + + // Set appropriate headers for JavaScript + r.RequestCtx.Response.Header.Set("Content-Type", "application/javascript") + r.RequestCtx.Response.Header.Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour + + // Serve the widget.js file from the embedded filesystem. + file, err := app.fs.Get("static/widget.js") + if err != nil { + return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError) + } + + r.RequestCtx.SetBody(file.ReadBytes()) + return nil +} + // getPagination extracts page and page_size from query params with defaults. // Defaults: page=1, pageSize=30 func getPagination(r *fastglue.Request) (page, pageSize int) { diff --git a/cmd/inboxes.go b/cmd/inboxes.go index 8a0f52cd..9a92eaf8 100644 --- a/cmd/inboxes.go +++ b/cmd/inboxes.go @@ -6,8 +6,8 @@ import ( "strconv" "github.com/abhinavxd/libredesk/internal/envelope" - "github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/inbox/channel/email/oauth" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" imodels "github.com/abhinavxd/libredesk/internal/inbox/models" "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" @@ -162,24 +162,54 @@ func handleDeleteInbox(r *fastglue.Request) error { } // validateInbox validates the inbox -func validateInbox(app *App, inb imodels.Inbox) error { - // Validate from address. - if _, err := mail.ParseAddress(inb.From); err != nil { - return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil) +func validateInbox(app *App, inbox imodels.Inbox) error { + // Validate from address only for email channels. + if inbox.Channel == "email" { + if _, err := mail.ParseAddress(inbox.From); err != nil { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil) + } } - if len(inb.Config) == 0 { + if len(inbox.Config) == 0 { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "config"), nil) } - if inb.Name == "" { + if inbox.Name == "" { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "name"), nil) } - if inb.Channel == "" { + 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) + } + } + + // Validate linked email inbox if specified + if inbox.LinkedEmailInboxID.Valid { + linkedInbox, err := app.inbox.GetDBRecord(int(inbox.LinkedEmailInboxID.Int)) + if err != nil { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "linked_email_inbox_id"), nil) + } + // Ensure linked inbox is an email channel + if linkedInbox.Channel != "email" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "linked_email_inbox_id"), nil) + } + // Ensure linked inbox is enabled + if !linkedInbox.Enabled { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "linked_email_inbox_id"), nil) + + } + } + } + // Validate email channel config. - if inb.Channel == inbox.ChannelEmail { - if err := validateEmailConfig(app, inb.Config); err != nil { + if inbox.Channel == "email" { + if err := validateEmailConfig(app, inbox.Config); err != nil { return err } } diff --git a/cmd/init.go b/cmd/init.go index ba214c82..799908ed 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -29,6 +29,7 @@ import ( "github.com/abhinavxd/libredesk/internal/importer" "github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/inbox/channel/email" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" imodels "github.com/abhinavxd/libredesk/internal/inbox/models" "github.com/abhinavxd/libredesk/internal/macro" "github.com/abhinavxd/libredesk/internal/media" @@ -37,6 +38,7 @@ import ( notifier "github.com/abhinavxd/libredesk/internal/notification" emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email" "github.com/abhinavxd/libredesk/internal/oidc" + "github.com/abhinavxd/libredesk/internal/ratelimit" "github.com/abhinavxd/libredesk/internal/report" "github.com/abhinavxd/libredesk/internal/role" "github.com/abhinavxd/libredesk/internal/search" @@ -159,7 +161,8 @@ func initConstants() *constants { // initFS initializes the stuffbin FileSystem. func initFS() stuffbin.FileSystem { var files = []string{ - "frontend/dist", + "frontend/dist/main", + "frontend/dist/widget", "i18n", "static", } @@ -251,11 +254,29 @@ func initConversations( webhook *webhook.Manager, dispatcher *notifier.Dispatcher, ) *conversation.Manager { + continuityConfig := &conversation.ContinuityConfig{} + + if ko.Exists("conversation.continuity.batch_check_interval") { + continuityConfig.BatchCheckInterval = ko.MustDuration("conversation.continuity.batch_check_interval") + } + + if ko.Exists("conversation.continuity.offline_threshold") { + continuityConfig.OfflineThreshold = ko.MustDuration("conversation.continuity.offline_threshold") + } + + if ko.Exists("conversation.continuity.min_email_interval") { + continuityConfig.MinEmailInterval = ko.MustDuration("conversation.continuity.min_email_interval") + } + + if ko.Exists("conversation.continuity.max_messages_per_email") { + continuityConfig.MaxMessagesPerEmail = ko.MustInt("conversation.continuity.max_messages_per_email") + } c, err := conversation.New(hub, i18n, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, webhook, dispatcher, conversation.Opts{ DB: db, Lo: initLogger("conversation_manager"), OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"), IncomingMessageQueueSize: ko.MustInt("message.incoming_queue_size"), + ContinuityConfig: continuityConfig, }) if err != nil { log.Fatalf("error initializing conversation manager: %v", err) @@ -640,12 +661,42 @@ func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrS return inbox, nil } +// initLiveChatInbox initializes the live chat inbox. +func initLiveChatInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) { + var config livechat.Config + + // Load JSON data into Koanf. + if err := ko.Load(rawbytes.Provider([]byte(inboxRecord.Config)), kjson.Parser()); err != nil { + return nil, fmt.Errorf("loading config: %w", err) + } + + if err := ko.UnmarshalWithConf("", &config, koanf.UnmarshalConf{Tag: "json"}); err != nil { + return nil, fmt.Errorf("unmarshalling `%s` %s config: %w", inboxRecord.Channel, inboxRecord.Name, err) + } + + inbox, err := livechat.New(msgStore, usrStore, livechat.Opts{ + ID: inboxRecord.ID, + Config: config, + Lo: initLogger("livechat_inbox"), + }) + + if err != nil { + return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err) + } + + log.Printf("`%s` inbox successfully initialized", inboxRecord.Name) + + return inbox, nil +} + // makeInboxInitializer creates an inbox initializer function. func makeInboxInitializer(mgr *inbox.Manager) func(imodels.Inbox, inbox.MessageStore, inbox.UserStore) (inbox.Inbox, error) { return func(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) { switch inboxR.Channel { case inbox.ChannelEmail: return initEmailInbox(inboxR, msgStore, usrStore, mgr) + case inbox.ChannelLiveChat: + return initLiveChatInbox(inboxR, msgStore, usrStore) default: return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel) } @@ -1010,3 +1061,12 @@ func getLogLevel(lvl string) logf.Level { return logf.InfoLevel } } + +// initRateLimit initializes the rate limiter. +func initRateLimit(redisClient *redis.Client) *ratelimit.Limiter { + var config ratelimit.Config + if err := ko.UnmarshalWithConf("rate_limit", &config, koanf.UnmarshalConf{Tag: "toml"}); err != nil { + log.Fatalf("error unmarshalling rate limit config: %v", err) + } + return ratelimit.New(redisClient, config) +} diff --git a/cmd/main.go b/cmd/main.go index 53c083bb..0c226c2a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -38,6 +38,7 @@ import ( "github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/media" "github.com/abhinavxd/libredesk/internal/oidc" + "github.com/abhinavxd/libredesk/internal/ratelimit" "github.com/abhinavxd/libredesk/internal/role" "github.com/abhinavxd/libredesk/internal/setting" "github.com/abhinavxd/libredesk/internal/tag" @@ -57,7 +58,8 @@ var ( ko = koanf.New(".") ctx = context.Background() appName = "libredesk" - frontendDir = "frontend/dist" + frontendDir = "frontend/dist/main" + widgetDir = "frontend/dist/widget" // Injected at build time. buildString string @@ -70,7 +72,6 @@ const ( // App is the global app context which is passed and injected in the http handlers. type App struct { - redis *redis.Client fs stuffbin.FileSystem consts atomic.Value auth *auth_.Auth @@ -103,6 +104,8 @@ type App struct { customAttribute *customAttribute.Manager report *report.Manager webhook *webhook.Manager + rateLimit *ratelimit.Limiter + redis *redis.Client importer *importer.Importer // Global state that stores data on an available app update. @@ -219,14 +222,20 @@ func main() { sla = initSLA(db, team, settings, businessHours, template, user, i18n, notifDispatcher) conversation = initConversations(i18n, sla, status, priority, wsHub, db, inbox, user, team, media, settings, csat, automation, template, webhook, notifDispatcher) autoassigner = initAutoAssigner(team, user, conversation) + rateLimiter = initRateLimit(rdb) ) + + 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) go conversation.RunUnsnoozer(ctx, unsnoozeInterval) + go conversation.RunContinuity(ctx) go webhook.Run(ctx) go notifier.Run(ctx) go sla.Run(ctx, slaEvaluationInterval) @@ -238,7 +247,6 @@ func main() { var app = &App{ lo: lo, - redis: rdb, fs: fs, sla: sla, oidc: oidc, @@ -253,12 +261,10 @@ func main() { priority: priority, tmpl: template, notifier: notifier, - userNotification: userNotification, consts: atomic.Value{}, conversation: conversation, automation: automation, businessHours: businessHours, - importer: initImporter(i18n), activityLog: initActivityLog(db, i18n), customAttribute: initCustomAttribute(db, i18n), authz: initAuthz(i18n), @@ -270,7 +276,11 @@ func main() { tag: initTag(db, i18n), macro: initMacro(db, i18n), ai: initAI(db, i18n), + importer: initImporter(i18n), webhook: webhook, + rateLimit: rateLimiter, + redis: rdb, + userNotification: userNotification, } app.consts.Store(constants) diff --git a/cmd/messages.go b/cmd/messages.go index d7151a25..23d24f7d 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -70,10 +70,11 @@ func handleGetMessages(r *fastglue.Request) error { att := messages[i].Attachments[j] messages[i].Attachments[j].URL = app.media.GetURL(att.UUID, att.ContentType, att.Name) } - // 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, @@ -107,8 +108,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 { att := message.Attachments[j] @@ -169,6 +172,16 @@ func handleSendMessage(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) } + // Make sure the inbox is enabled. + inbox, err := app.inbox.GetDBRecord(conv.InboxID) + if err != nil { + return sendErrorEnvelope(r, err) + } + if !inbox.Enabled { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + // Prepare attachments. if req.SenderType != umodels.UserTypeAgent && req.SenderType != umodels.UserTypeContact { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`sender_type`"), nil, envelope.InputError) } @@ -227,8 +240,7 @@ func handleSendMessage(r *fastglue.Request) error { return r.SendEnvelope(message) } - // Queue reply. - message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/) + message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, conv.ContactID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/upgrade.go b/cmd/upgrade.go index bb0cf3be..c2e0c8bb 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -39,6 +39,7 @@ var migList = []migFunc{ {"v0.8.5", migrations.V0_8_5}, {"v0.9.1", migrations.V0_9_1}, {"v0.10.0", migrations.V0_10_0}, + {"v0.12.0", migrations.V0_12_0}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/cmd/widget_middleware.go b/cmd/widget_middleware.go new file mode 100644 index 00000000..88046b0f --- /dev/null +++ b/cmd/widget_middleware.go @@ -0,0 +1,167 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + "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" +) + +const ( + // Context keys for storing authenticated widget data + ctxWidgetClaims = "widget_claims" + ctxWidgetInboxID = "widget_inbox_id" + ctxWidgetContactID = "widget_contact_id" + ctxWidgetInbox = "widget_inbox" + + // Header sent in every widget request to identify the inbox + hdrWidgetInboxID = "X-Libredesk-Inbox-ID" +) + +// widgetAuth middleware authenticates widget requests using JWT and inbox validation. +// It always validates the inbox from X-Libredesk-Inbox-ID header, and conditionally validates JWT. +// For /conversations/init without JWT, it allows visitor creation while still validating inbox. +func widgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) error { + return func(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + ) + + // Always extract and validate inbox_id from custom header + inboxIDHeader := string(r.RequestCtx.Request.Header.Peek(hdrWidgetInboxID)) + if inboxIDHeader == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + inboxID, err := strconv.Atoi(inboxIDHeader) + if err != nil || inboxID <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + // Always fetch and validate inbox + inbox, err := app.inbox.GetDBRecord(inboxID) + if err != nil { + app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err) + return sendErrorEnvelope(r, err) + } + + if !inbox.Enabled { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + // Check if inbox is the correct type for widget requests + if inbox.Channel != livechat.ChannelLiveChat { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + // Always store inbox data in context + r.RequestCtx.SetUserValue(ctxWidgetInboxID, inboxID) + r.RequestCtx.SetUserValue(ctxWidgetInbox, inbox) + + // Extract JWT from Authorization header (Bearer token) + authHeader := string(r.RequestCtx.Request.Header.Peek("Authorization")) + + // For init endpoint, allow requests without JWT (visitor creation) + if authHeader == "" && strings.Contains(string(r.RequestCtx.Path()), "/conversations/init") { + return next(r) + } + + // For all other requests, require JWT + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) + } + jwtToken := strings.TrimPrefix(authHeader, "Bearer ") + + // Verify JWT using inbox secret + claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String) + if err != nil { + app.lo.Error("invalid JWT", "jwt", jwtToken, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) + } + + // Resolve user/contact ID from JWT claims + contactID, err := resolveUserIDFromClaims(app, claims) + if err != nil { + envErr, ok := err.(envelope.Error) + if ok && envErr.ErrorType != envelope.NotFoundError { + app.lo.Error("error resolving user ID from JWT claims", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + } + + // Store authenticated data in request context for downstream handlers + r.RequestCtx.SetUserValue(ctxWidgetClaims, claims) + r.RequestCtx.SetUserValue(ctxWidgetContactID, contactID) + + return next(r) + } +} + +// Helper functions to extract authenticated data from request context + +// getWidgetInboxID extracts inbox ID from request context +func getWidgetInboxID(r *fastglue.Request) (int, error) { + val := r.RequestCtx.UserValue(ctxWidgetInboxID) + if val == nil { + return 0, fmt.Errorf("widget middleware not applied: missing inbox ID in context") + } + inboxID, ok := val.(int) + if !ok { + return 0, fmt.Errorf("invalid inbox ID type in context") + } + return inboxID, nil +} + +// getWidgetContactID extracts contact ID from request context +func getWidgetContactID(r *fastglue.Request) (int, error) { + val := r.RequestCtx.UserValue(ctxWidgetContactID) + if val == nil { + return 0, fmt.Errorf("widget middleware not applied: missing contact ID in context") + } + contactID, ok := val.(int) + if !ok { + return 0, fmt.Errorf("invalid contact ID type in context") + } + return contactID, nil +} + +// getWidgetInbox extracts inbox model from request context +func getWidgetInbox(r *fastglue.Request) (imodels.Inbox, error) { + val := r.RequestCtx.UserValue(ctxWidgetInbox) + if val == nil { + return imodels.Inbox{}, fmt.Errorf("widget middleware not applied: missing inbox in context") + } + inbox, ok := val.(imodels.Inbox) + if !ok { + return imodels.Inbox{}, fmt.Errorf("invalid inbox type in context") + } + return inbox, nil +} + +// getWidgetClaimsOptional extracts JWT claims from request context, returns nil if not set +func getWidgetClaimsOptional(r *fastglue.Request) *Claims { + val := r.RequestCtx.UserValue(ctxWidgetClaims) + if val == nil { + return nil + } + if claims, ok := val.(Claims); ok { + return &claims + } + return nil +} + +// rateLimitWidget applies rate limiting to widget endpoints. +func rateLimitWidget(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { + return func(r *fastglue.Request) error { + app := r.Context.(*App) + if err := app.rateLimit.CheckWidgetLimit(r.RequestCtx); err != nil { + return err + } + return handler(r) + } +} diff --git a/cmd/widget_ws.go b/cmd/widget_ws.go new file mode 100644 index 00000000..13165fab --- /dev/null +++ b/cmd/widget_ws.go @@ -0,0 +1,288 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" + "github.com/fasthttp/websocket" + "github.com/zerodha/fastglue" +) + +// Widget WebSocket message types +const ( + WidgetMsgTypeJoin = "join" + WidgetMsgTypeMessage = "message" + WidgetMsgTypeTyping = "typing" + WidgetMsgTypePing = "ping" + WidgetMsgTypePong = "pong" + WidgetMsgTypeError = "error" + WidgetMsgTypeNewMsg = "new_message" + WidgetMsgTypeStatus = "status" + WidgetMsgTypeJoined = "joined" +) + +// WidgetMessage represents a message sent through the widget WebSocket +type WidgetMessage struct { + Type string `json:"type"` + JWT string `json:"jwt,omitempty"` + Data any `json:"data"` +} + +type WidgetInboxJoinRequest struct { + InboxID int `json:"inbox_id"` +} + +// WidgetMessageData represents a chat message through the widget +type WidgetMessageData struct { + ConversationUUID string `json:"conversation_uuid"` + Content string `json:"content"` + SenderName string `json:"sender_name,omitempty"` + SenderType string `json:"sender_type"` + Timestamp int64 `json:"timestamp"` +} + +// WidgetTypingData represents typing indicator data +type WidgetTypingData struct { + ConversationUUID string `json:"conversation_uuid"` + IsTyping bool `json:"is_typing"` +} + +// handleWidgetWS handles the widget WebSocket connection for live chat. +func handleWidgetWS(r *fastglue.Request) error { + var app = r.Context.(*App) + + if err := upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) { + // To store client and live chat references for cleanup. + var client *livechat.Client + var liveChat *livechat.LiveChat + var inboxID int + + // Clean up client when connection closes. + defer func() { + conn.Close() + if client != nil && liveChat != nil { + liveChat.RemoveClient(client) + close(client.Channel) + app.lo.Debug("cleaned up client on websocket disconnect", "client_id", client.ID) + } + }() + + // Read messages from the WebSocket connection. + for { + var msg WidgetMessage + if err := conn.ReadJSON(&msg); err != nil { + app.lo.Debug("widget websocket connection closed", "error", err) + break + } + + switch msg.Type { + // Inbox join request. + case WidgetMsgTypeJoin: + var joinedClient *livechat.Client + var joinedLiveChat *livechat.LiveChat + var joinedInboxID int + var err error + if joinedClient, joinedLiveChat, joinedInboxID, err = handleInboxJoin(app, conn, &msg); err != nil { + app.lo.Error("error handling widget join", "error", err) + sendWidgetError(conn, "Failed to join conversation") + continue + } + // Store the client, livechat, and inbox ID for cleanup and future use. + client = joinedClient + liveChat = joinedLiveChat + inboxID = joinedInboxID + // Typing. + case WidgetMsgTypeTyping: + if err := handleWidgetTyping(app, &msg); err != nil { + app.lo.Error("error handling widget typing", "error", err) + continue + } + // Ping. + case WidgetMsgTypePing: + // Update user's last active timestamp if JWT is provided and client has joined + if msg.JWT != "" && inboxID != 0 { + if claims, err := validateWidgetMessageJWT(app, msg.JWT, inboxID); err == nil { + if userID, err := resolveUserIDFromClaims(app, claims); err == nil { + if err := app.user.UpdateLastActive(userID); err != nil { + app.lo.Error("error updating user last active timestamp", "user_id", userID, "error", err) + } else { + app.lo.Debug("updated user last active timestamp", "user_id", userID) + } + } + } + } + + if err := conn.WriteJSON(WidgetMessage{ + Type: WidgetMsgTypePong, + }); err != nil { + app.lo.Error("error writing pong to widget client", "error", err) + } + } + } + }); err != nil { + app.lo.Error("error upgrading widget websocket connection", "error", err) + } + return nil +} + +// handleInboxJoin handles a websocket join request for a live chat inbox. +func handleInboxJoin(app *App, conn *websocket.Conn, msg *WidgetMessage) (*livechat.Client, *livechat.LiveChat, int, error) { + joinDataBytes, err := json.Marshal(msg.Data) + if err != nil { + return nil, nil, 0, fmt.Errorf("invalid join data: %w", err) + } + + var joinData WidgetInboxJoinRequest + if err := json.Unmarshal(joinDataBytes, &joinData); err != nil { + return nil, nil, 0, fmt.Errorf("invalid join data format: %w", err) + } + + // Validate JWT with inbox secret + claims, err := validateWidgetMessageJWT(app, msg.JWT, joinData.InboxID) + if err != nil { + return nil, nil, 0, fmt.Errorf("JWT validation failed: %w", err) + } + + // Resolve user ID. + userID, err := resolveUserIDFromClaims(app, claims) + if err != nil { + return nil, nil, 0, fmt.Errorf("failed to resolve user ID from claims: %w", err) + } + + // Make sure inbox is active. + inbox, err := app.inbox.GetDBRecord(joinData.InboxID) + if err != nil { + return nil, nil, 0, fmt.Errorf("inbox not found: %w", err) + } + if !inbox.Enabled { + return nil, nil, 0, fmt.Errorf("inbox is not enabled") + } + + // Get live chat inbox + lcInbox, err := app.inbox.Get(inbox.ID) + if err != nil { + return nil, nil, 0, fmt.Errorf("live chat inbox not found: %w", err) + } + + // Assert type. + liveChat, ok := lcInbox.(*livechat.LiveChat) + if !ok { + return nil, nil, 0, fmt.Errorf("inbox is not a live chat inbox") + } + + // Add client to live chat session + userIDStr := fmt.Sprintf("%d", userID) + client, err := liveChat.AddClient(userIDStr) + if err != nil { + app.lo.Error("error adding client to live chat", "error", err, "user_id", userIDStr) + return nil, nil, 0, err + } + + // Start listening for messages from the live chat channel. + go func() { + for msgData := range client.Channel { + if err := conn.WriteMessage(websocket.TextMessage, msgData); err != nil { + app.lo.Error("error forwarding message to widget client", "error", err) + return + } + } + }() + + // Send join confirmation + joinResp := WidgetMessage{ + Type: WidgetMsgTypeJoined, + Data: map[string]string{ + "message": "namaste!", + }, + } + + if err := conn.WriteJSON(joinResp); err != nil { + return nil, nil, 0, err + } + + app.lo.Debug("widget client joined live chat", "user_id", userIDStr, "inbox_id", joinData.InboxID) + + return client, liveChat, joinData.InboxID, nil +} + +// handleWidgetTyping handles typing indicators +func handleWidgetTyping(app *App, msg *WidgetMessage) error { + typingDataBytes, err := json.Marshal(msg.Data) + if err != nil { + app.lo.Error("error marshalling typing data", "error", err) + return fmt.Errorf("invalid typing data: %w", err) + } + + var typingData WidgetTypingData + if err := json.Unmarshal(typingDataBytes, &typingData); err != nil { + app.lo.Error("error unmarshalling typing data", "error", err) + return fmt.Errorf("invalid typing data format: %w", err) + } + + // Get conversation to retrieve inbox ID for JWT validation + if typingData.ConversationUUID == "" { + return fmt.Errorf("conversation UUID is required for typing messages") + } + + conversation, err := app.conversation.GetConversation(0, typingData.ConversationUUID, "") + if err != nil { + app.lo.Error("error fetching conversation for typing", "conversation_uuid", typingData.ConversationUUID, "error", err) + return fmt.Errorf("conversation not found: %w", err) + } + + // Validate JWT with inbox secret + claims, err := validateWidgetMessageJWT(app, msg.JWT, conversation.InboxID) + if err != nil { + return fmt.Errorf("JWT validation failed: %w", err) + } + + userID := claims.UserID + + // Broadcast typing status to agents via conversation manager + // Set broadcastToWidgets=false to avoid echoing back to widget clients + 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 +} + +// validateWidgetMessageJWT validates the incoming widget message JWT using inbox secret +func validateWidgetMessageJWT(app *App, jwtToken string, inboxID int) (Claims, error) { + if jwtToken == "" { + return Claims{}, fmt.Errorf("JWT token is empty") + } + + if inboxID <= 0 { + return Claims{}, fmt.Errorf("inbox ID is required for JWT validation") + } + + // Get inbox to retrieve secret for JWT verification + inbox, err := app.inbox.GetDBRecord(inboxID) + if err != nil { + return Claims{}, fmt.Errorf("inbox not found: %w", err) + } + + if !inbox.Secret.Valid { + return Claims{}, fmt.Errorf("inbox secret not configured for JWT verification") + } + + // Use the existing verifyStandardJWT function which properly validates with inbox secret + claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String) + if err != nil { + return Claims{}, fmt.Errorf("JWT validation failed: %w", err) + } + + return claims, nil +} + +// sendWidgetError sends an error message to the widget client +func sendWidgetError(conn *websocket.Conn, message string) { + errorMsg := WidgetMessage{ + Type: WidgetMsgTypeError, + Data: map[string]string{ + "message": message, + }, + } + conn.WriteJSON(errorMsg) +} diff --git a/config.sample.toml b/config.sample.toml index 772df8e4..55c2b603 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -126,6 +126,17 @@ unsnooze_interval = "5m" # How long to keep drafts before deleting them from the database. (e.g. "360h", "48h") draft_retention_period = "360h" +[conversation.continuity] +offline_threshold = "10m" +batch_check_interval = "5m" +max_messages_per_email = 10 +min_email_interval = "15m" + [sla] # How often to evaluate SLA compliance for conversations evaluation_interval = "5m" + +[rate_limit] + [rate_limit.widget] + enabled = true + requests_per_minute = 100 diff --git a/frontend/README-SETUP.md b/frontend/README-SETUP.md new file mode 100644 index 00000000..2f30baa2 --- /dev/null +++ b/frontend/README-SETUP.md @@ -0,0 +1,59 @@ +# Libredesk Frontend - Multi-App Setup + +This frontend supports both the main Libredesk application and a chat widget as separate Vue applications sharing common UI components. + +## Project Structure + +``` +frontend/ +├── apps/ +│ ├── main/ # Main Libredesk application +│ │ ├── src/ +│ │ └── index.html +│ └── widget/ # Chat widget application +│ ├── src/ +│ └── index.html +├── shared-ui/ # Shared UI components (shadcn/ui) +│ ├── components/ +│ │ └── ui/ # shadcn/ui components +│ ├── lib/ # Utility functions +│ └── assets/ # Shared styles +└── package.json +``` + +## Development + +Check Makefile for available commands. + +## Shared UI Components + +The `shared-ui` directory contains all the shadcn/ui components that can be used in both apps. + +### Using Shared Components + +```vue + + + +``` + +### Path Aliases + +- `@shared-ui` - Points to the shared-ui directory +- `@main` - Points to apps/main/src +- `@widget` - Points to apps/widget/src +- `@` - Points to the current app's src directory (context-dependent) diff --git a/frontend/index.html b/frontend/apps/main/index.html similarity index 100% rename from frontend/index.html rename to frontend/apps/main/index.html diff --git a/frontend/src/App.vue b/frontend/apps/main/src/App.vue similarity index 88% rename from frontend/src/App.vue rename to frontend/apps/main/src/App.vue index 546e5630..865a76d3 100644 --- a/frontend/src/App.vue +++ b/frontend/apps/main/src/App.vue @@ -127,27 +127,26 @@ diff --git a/frontend/src/api/index.js b/frontend/apps/main/src/api/index.js similarity index 100% rename from frontend/src/api/index.js rename to frontend/apps/main/src/api/index.js diff --git a/frontend/apps/main/src/components/banner/AdminBanner.vue b/frontend/apps/main/src/components/banner/AdminBanner.vue new file mode 100644 index 00000000..0805b6c5 --- /dev/null +++ b/frontend/apps/main/src/components/banner/AdminBanner.vue @@ -0,0 +1,63 @@ + + + diff --git a/frontend/src/components/button/CloseButton.vue b/frontend/apps/main/src/components/button/CloseButton.vue similarity index 87% rename from frontend/src/components/button/CloseButton.vue rename to frontend/apps/main/src/components/button/CloseButton.vue index f10a214e..74b89ff5 100644 --- a/frontend/src/components/button/CloseButton.vue +++ b/frontend/apps/main/src/components/button/CloseButton.vue @@ -12,7 +12,7 @@ diff --git a/frontend/apps/main/src/features/admin/inbox/PreChatFormConfig.vue b/frontend/apps/main/src/features/admin/inbox/PreChatFormConfig.vue new file mode 100644 index 00000000..1417332c --- /dev/null +++ b/frontend/apps/main/src/features/admin/inbox/PreChatFormConfig.vue @@ -0,0 +1,278 @@ + + + diff --git a/frontend/src/features/admin/inbox/formSchema.js b/frontend/apps/main/src/features/admin/inbox/formSchema.js similarity index 94% rename from frontend/src/features/admin/inbox/formSchema.js rename to frontend/apps/main/src/features/admin/inbox/formSchema.js index 69d3db21..9ddc4b4e 100644 --- a/frontend/src/features/admin/inbox/formSchema.js +++ b/frontend/apps/main/src/features/admin/inbox/formSchema.js @@ -1,6 +1,6 @@ import * as z from 'zod' -import { isGoDuration } from '@/utils/strings' -import { AUTH_TYPE_PASSWORD, AUTH_TYPE_OAUTH2 } from '@/constants/auth.js' +import { isGoDuration } from '@shared-ui/utils/string' +import { AUTH_TYPE_PASSWORD, AUTH_TYPE_OAUTH2 } from '@main/constants/auth.js' export const createFormSchema = (t) => z.object({ name: z.string().min(1, t('globals.messages.required')), diff --git a/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js b/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js new file mode 100644 index 00000000..3c5e3f23 --- /dev/null +++ b/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js @@ -0,0 +1,87 @@ +import { z } from 'zod' + +export const createFormSchema = (t) => z.object({ + name: z.string().min(1, { message: t('globals.messages.required') }), + enabled: z.boolean(), + csat_enabled: z.boolean(), + secret: z.string().nullable().optional(), + linked_email_inbox_id: z.number().nullable().optional(), + config: z.object({ + brand_name: z.string().min(1, { message: t('globals.messages.required') }), + dark_mode: z.boolean(), + show_powered_by: z.boolean(), + language: z.string().min(1, { message: t('globals.messages.required') }), + logo_url: z.string().url({ + message: t('globals.messages.invalid', { + name: t('globals.terms.url').toLowerCase() + }) + }).optional().or(z.literal('')), + launcher: z.object({ + position: z.enum(['left', 'right']), + logo_url: z.string().url({ + message: t('globals.messages.invalid', { + name: t('globals.terms.url').toLowerCase() + }) + }).optional().or(z.literal('')), + spacing: z.object({ + side: z.number().min(0), + bottom: z.number().min(0), + }) + }), + greeting_message: z.string().optional(), + introduction_message: z.string().optional(), + chat_introduction: z.string(), + show_office_hours_in_chat: z.boolean(), + show_office_hours_after_assignment: z.boolean(), + notice_banner: z.object({ + enabled: z.boolean(), + text: z.string().optional() + }), + colors: z.object({ + primary: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, { + message: t('globals.messages.invalid', { + name: t('admin.inbox.livechat.colors').toLowerCase() + }) + }), + }), + features: z.object({ + file_upload: z.boolean(), + emoji: z.boolean(), + }), + direct_to_conversation: z.boolean().default(false), + trusted_domains: z.string().optional(), + external_links: z.array(z.object({ + text: z.string().min(1), + url: z.string().url({ + message: t('globals.messages.invalid', { + name: t('globals.terms.url').toLowerCase() + }) + }) + })), + visitors: z.object({ + start_conversation_button_text: z.string(), + allow_start_conversation: z.boolean(), + prevent_multiple_conversations: z.boolean(), + }), + users: z.object({ + start_conversation_button_text: z.string(), + allow_start_conversation: z.boolean(), + prevent_multiple_conversations: z.boolean(), + }), + prechat_form: z.object({ + enabled: z.boolean(), + title: z.string().optional(), + fields: z.array(z.object({ + key: z.string().min(1), + type: z.enum(['text', 'email', 'number', 'checkbox', 'date', 'link', 'list']), + label: z.string().min(1, { message: t('globals.messages.required') }), + placeholder: z.string().optional(), + required: z.boolean(), + enabled: z.boolean(), + order: z.number().min(1), + is_default: z.boolean(), + custom_attribute_id: z.number().optional() + })) + }) + }) +}) diff --git a/frontend/src/features/admin/macros/ActionBuilder.vue b/frontend/apps/main/src/features/admin/macros/ActionBuilder.vue similarity index 94% rename from frontend/src/features/admin/macros/ActionBuilder.vue rename to frontend/apps/main/src/features/admin/macros/ActionBuilder.vue index e2dbd177..c93f8d63 100644 --- a/frontend/src/features/admin/macros/ActionBuilder.vue +++ b/frontend/apps/main/src/features/admin/macros/ActionBuilder.vue @@ -129,7 +129,7 @@ diff --git a/frontend/src/features/admin/status/dataTableColumns.js b/frontend/apps/main/src/features/admin/status/dataTableColumns.js similarity index 100% rename from frontend/src/features/admin/status/dataTableColumns.js rename to frontend/apps/main/src/features/admin/status/dataTableColumns.js diff --git a/frontend/src/features/admin/status/dataTableDropdown.vue b/frontend/apps/main/src/features/admin/status/dataTableDropdown.vue similarity index 88% rename from frontend/src/features/admin/status/dataTableDropdown.vue rename to frontend/apps/main/src/features/admin/status/dataTableDropdown.vue index 5f2cf9a5..2ed86443 100644 --- a/frontend/src/features/admin/status/dataTableDropdown.vue +++ b/frontend/apps/main/src/features/admin/status/dataTableDropdown.vue @@ -76,7 +76,7 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' +} from '@shared-ui/components/ui/dropdown-menu/index.js' import { AlertDialog, AlertDialogAction, @@ -86,8 +86,8 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle -} from '@/components/ui/alert-dialog' -import { Button } from '@/components/ui/button' +} from '@shared-ui/components/ui/alert-dialog/index.js' +import { Button } from '@shared-ui/components/ui/button/index.js' import { useForm } from 'vee-validate' import { toTypedSchema } from '@vee-validate/zod' import { createFormSchema } from './formSchema.js' @@ -100,13 +100,13 @@ import { DialogHeader, DialogTitle, DialogTrigger -} from '@/components/ui/dialog' -import { CONVERSATION_DEFAULT_STATUSES_LIST } from '@/constants/conversation.js' -import { useEmitter } from '@/composables/useEmitter' -import { handleHTTPError } from '@/utils/http' -import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' +} from '@shared-ui/components/ui/dialog/index.js' +import { CONVERSATION_DEFAULT_STATUSES_LIST } from '../../../constants/conversation.js' +import { useEmitter } from '../../../composables/useEmitter.js' +import { handleHTTPError } from '../../../utils/http.js' +import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js' import { useI18n } from 'vue-i18n' -import api from '@/api/index.js' +import api from '../../../api/index.js' const { t } = useI18n() const isLoading = ref(false) diff --git a/frontend/src/features/admin/status/formSchema.js b/frontend/apps/main/src/features/admin/status/formSchema.js similarity index 100% rename from frontend/src/features/admin/status/formSchema.js rename to frontend/apps/main/src/features/admin/status/formSchema.js diff --git a/frontend/src/features/admin/tags/TagsForm.vue b/frontend/apps/main/src/features/admin/tags/TagsForm.vue similarity index 87% rename from frontend/src/features/admin/tags/TagsForm.vue rename to frontend/apps/main/src/features/admin/tags/TagsForm.vue index b485c207..d1a23efc 100644 --- a/frontend/src/features/admin/tags/TagsForm.vue +++ b/frontend/apps/main/src/features/admin/tags/TagsForm.vue @@ -23,6 +23,6 @@ import { FormItem, FormLabel, FormMessage -} from '@/components/ui/form' -import { Input } from '@/components/ui/input' +} from '@shared-ui/components/ui/form' +import { Input } from '@shared-ui/components/ui/input' \ No newline at end of file diff --git a/frontend/src/features/admin/tags/dataTableColumns.js b/frontend/apps/main/src/features/admin/tags/dataTableColumns.js similarity index 100% rename from frontend/src/features/admin/tags/dataTableColumns.js rename to frontend/apps/main/src/features/admin/tags/dataTableColumns.js diff --git a/frontend/src/features/admin/tags/dataTableDropdown.vue b/frontend/apps/main/src/features/admin/tags/dataTableDropdown.vue similarity index 90% rename from frontend/src/features/admin/tags/dataTableDropdown.vue rename to frontend/apps/main/src/features/admin/tags/dataTableDropdown.vue index 7d403c85..1c24462e 100644 --- a/frontend/src/features/admin/tags/dataTableDropdown.vue +++ b/frontend/apps/main/src/features/admin/tags/dataTableDropdown.vue @@ -61,8 +61,8 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { Button } from '@/components/ui/button' +} from '@shared-ui/components/ui/dropdown-menu/index.js' +import { Button } from '@shared-ui/components/ui/button/index.js' import { useForm } from 'vee-validate' import { toTypedSchema } from '@vee-validate/zod' import { createFormSchema } from './formSchema.js' @@ -74,7 +74,7 @@ import { DialogHeader, DialogTitle, DialogTrigger -} from '@/components/ui/dialog' +} from '@shared-ui/components/ui/dialog/index.js' import { AlertDialog, AlertDialogAction, @@ -84,12 +84,12 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle -} from '@/components/ui/alert-dialog' -import { useEmitter } from '@/composables/useEmitter' -import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' +} from '@shared-ui/components/ui/alert-dialog/index.js' +import { useEmitter } from '../../../composables/useEmitter.js' +import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js' import TagsForm from './TagsForm.vue' import { useI18n } from 'vue-i18n' -import api from '@/api/index.js' +import api from '../../../api/index.js' const { t } = useI18n() const dialogOpen = ref(false) diff --git a/frontend/src/features/admin/tags/formSchema.js b/frontend/apps/main/src/features/admin/tags/formSchema.js similarity index 100% rename from frontend/src/features/admin/tags/formSchema.js rename to frontend/apps/main/src/features/admin/tags/formSchema.js diff --git a/frontend/src/features/admin/teams/TeamDataTableDropdown.vue b/frontend/apps/main/src/features/admin/teams/TeamDataTableDropdown.vue similarity index 85% rename from frontend/src/features/admin/teams/TeamDataTableDropdown.vue rename to frontend/apps/main/src/features/admin/teams/TeamDataTableDropdown.vue index d12310ac..c8d5198b 100644 --- a/frontend/src/features/admin/teams/TeamDataTableDropdown.vue +++ b/frontend/apps/main/src/features/admin/teams/TeamDataTableDropdown.vue @@ -36,7 +36,7 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' +} from '@shared-ui/components/ui/dropdown-menu' import { AlertDialog, AlertDialogAction, @@ -46,13 +46,13 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle -} from '@/components/ui/alert-dialog' -import { Button } from '@/components/ui/button' +} from '@shared-ui/components/ui/alert-dialog' +import { Button } from '@shared-ui/components/ui/button' import { useRouter } from 'vue-router' -import { useEmitter } from '@/composables/useEmitter' -import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' -import { handleHTTPError } from '@/utils/http' -import api from '@/api' +import { useEmitter } from '../../../composables/useEmitter' +import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js' +import { handleHTTPError } from '../../../utils/http' +import api from '../../../api' const alertOpen = ref(false) const router = useRouter() diff --git a/frontend/src/features/admin/teams/TeamForm.vue b/frontend/apps/main/src/features/admin/teams/TeamForm.vue similarity index 93% rename from frontend/src/features/admin/teams/TeamForm.vue rename to frontend/apps/main/src/features/admin/teams/TeamForm.vue index ede86866..308c40cb 100644 --- a/frontend/src/features/admin/teams/TeamForm.vue +++ b/frontend/apps/main/src/features/admin/teams/TeamForm.vue @@ -148,7 +148,7 @@ diff --git a/frontend/src/features/conversation/message/ActivityMessageBubble.vue b/frontend/apps/main/src/features/conversation/message/ActivityMessageBubble.vue similarity index 86% rename from frontend/src/features/conversation/message/ActivityMessageBubble.vue rename to frontend/apps/main/src/features/conversation/message/ActivityMessageBubble.vue index d4bd641e..cca80e79 100644 --- a/frontend/src/features/conversation/message/ActivityMessageBubble.vue +++ b/frontend/apps/main/src/features/conversation/message/ActivityMessageBubble.vue @@ -18,7 +18,7 @@ diff --git a/frontend/src/features/conversation/message/MessageBubble.vue b/frontend/apps/main/src/features/conversation/message/MessageBubble.vue similarity index 92% rename from frontend/src/features/conversation/message/MessageBubble.vue rename to frontend/apps/main/src/features/conversation/message/MessageBubble.vue index 88fb38ea..04bd1eef 100644 --- a/frontend/src/features/conversation/message/MessageBubble.vue +++ b/frontend/apps/main/src/features/conversation/message/MessageBubble.vue @@ -117,19 +117,19 @@ diff --git a/frontend/src/views/admin/inbox/NewInbox.vue b/frontend/apps/main/src/views/admin/inbox/NewInbox.vue similarity index 78% rename from frontend/src/views/admin/inbox/NewInbox.vue rename to frontend/apps/main/src/views/admin/inbox/NewInbox.vue index 1ac41435..5cddcbc0 100644 --- a/frontend/src/views/admin/inbox/NewInbox.vue +++ b/frontend/apps/main/src/views/admin/inbox/NewInbox.vue @@ -51,7 +51,7 @@
-
+
@@ -68,6 +69,9 @@
+
+ +
@@ -81,23 +85,24 @@ + + + \ No newline at end of file diff --git a/frontend/apps/widget/src/App.vue b/frontend/apps/widget/src/App.vue new file mode 100644 index 00000000..ab85e987 --- /dev/null +++ b/frontend/apps/widget/src/App.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/apps/widget/src/api/index.js b/frontend/apps/widget/src/api/index.js new file mode 100644 index 00000000..62fff15a --- /dev/null +++ b/frontend/apps/widget/src/api/index.js @@ -0,0 +1,98 @@ +import axios from 'axios' + +function getInboxIDFromQuery () { + const params = new URLSearchParams(window.location.search) + const inboxId = params.get('inbox_id') + return inboxId ? parseInt(inboxId, 10) : null +} + +const http = axios.create({ + timeout: 10000, + responseType: 'json' +}) + +// Set content type and authentication headers +http.interceptors.request.use((request) => { + if ((request.method === 'post' || request.method === 'put') && !request.headers['Content-Type']) { + request.headers['Content-Type'] = 'application/json' + } + + // Add authentication headers for widget API endpoints + if (request.url && request.url.includes('/api/v1/widget/')) { + const libredeskSession = localStorage.getItem('libredesk_session') + const inboxId = getInboxIDFromQuery() + + // Add JWT to Authorization header + if (libredeskSession) { + request.headers['Authorization'] = `Bearer ${libredeskSession}` + } + + // Add inbox ID to custom header + if (inboxId) { + request.headers['X-Libredesk-Inbox-ID'] = inboxId.toString() + } + } + + return request +}) + +const getWidgetSettings = (inboxID) => http.get('/api/v1/widget/chat/settings', { + params: { inbox_id: inboxID } +}) +const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`) +const initChatConversation = (data) => http.post('/api/v1/widget/chat/conversations/init', data) +const getChatConversations = () => http.get('/api/v1/widget/chat/conversations') +const getChatConversation = (uuid) => http.get(`/api/v1/widget/chat/conversations/${uuid}`) +const sendChatMessage = (uuid, data) => http.post(`/api/v1/widget/chat/conversations/${uuid}/message`, data) +const closeChatConversation = (uuid) => http.post(`/api/v1/widget/chat/conversations/${uuid}/close`) +const uploadMedia = (conversationUUID, files) => { + const formData = new FormData() + + // Only add conversation UUID to form data now + formData.append('conversation_uuid', conversationUUID) + + // Add files + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]) + } + + // Get authentication data for headers + const libredeskSession = localStorage.getItem('libredesk_session') + const inboxId = getInboxIDFromQuery() + + const headers = { + 'Content-Type': 'multipart/form-data' + } + + // Add authentication headers + if (libredeskSession) { + headers['Authorization'] = `Bearer ${libredeskSession}` + } + if (inboxId) { + headers['X-Libredesk-Inbox-ID'] = inboxId.toString() + } + + return axios.post('/api/v1/widget/media/upload', formData, { + headers, + timeout: 30000 + }) +} +const updateConversationLastSeen = (uuid) => http.post(`/api/v1/widget/chat/conversations/${uuid}/update-last-seen`) +const submitCSATResponse = (csatUuid, rating, feedback) => + http.post(`/api/v1/csat/${csatUuid}/response`, { + rating, + feedback, + }) + +export default { + getWidgetSettings, + getLanguage, + initChatConversation, + getChatConversations, + getChatConversation, + sendChatMessage, + closeChatConversation, + uploadMedia, + updateConversationLastSeen, + submitCSATResponse +} diff --git a/frontend/apps/widget/src/assets/widget.css b/frontend/apps/widget/src/assets/widget.css new file mode 100644 index 00000000..e69de29b diff --git a/frontend/apps/widget/src/components/CSATMessageBubble.vue b/frontend/apps/widget/src/components/CSATMessageBubble.vue new file mode 100644 index 00000000..a8d00eac --- /dev/null +++ b/frontend/apps/widget/src/components/CSATMessageBubble.vue @@ -0,0 +1,117 @@ + + + diff --git a/frontend/apps/widget/src/components/ChatHeader.vue b/frontend/apps/widget/src/components/ChatHeader.vue new file mode 100644 index 00000000..58203191 --- /dev/null +++ b/frontend/apps/widget/src/components/ChatHeader.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/apps/widget/src/components/ChatIntro.vue b/frontend/apps/widget/src/components/ChatIntro.vue new file mode 100644 index 00000000..0726cb47 --- /dev/null +++ b/frontend/apps/widget/src/components/ChatIntro.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/apps/widget/src/components/ChatMessages.vue b/frontend/apps/widget/src/components/ChatMessages.vue new file mode 100644 index 00000000..e739d319 --- /dev/null +++ b/frontend/apps/widget/src/components/ChatMessages.vue @@ -0,0 +1,312 @@ + + + diff --git a/frontend/apps/widget/src/components/ChatTitle.vue b/frontend/apps/widget/src/components/ChatTitle.vue new file mode 100644 index 00000000..a06a20ba --- /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..457b3427 --- /dev/null +++ b/frontend/apps/widget/src/components/CloseWidgetButton.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/apps/widget/src/components/ConnectionBanner.vue b/frontend/apps/widget/src/components/ConnectionBanner.vue new file mode 100644 index 00000000..6e0648b6 --- /dev/null +++ b/frontend/apps/widget/src/components/ConnectionBanner.vue @@ -0,0 +1,16 @@ + + + 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..c8f313d6 --- /dev/null +++ b/frontend/apps/widget/src/components/HomeHeader.vue @@ -0,0 +1,46 @@ + + + diff --git a/frontend/apps/widget/src/components/MessageAttachment.vue b/frontend/apps/widget/src/components/MessageAttachment.vue new file mode 100644 index 00000000..c450deda --- /dev/null +++ b/frontend/apps/widget/src/components/MessageAttachment.vue @@ -0,0 +1,69 @@ + + + diff --git a/frontend/apps/widget/src/components/MessageInput.vue b/frontend/apps/widget/src/components/MessageInput.vue new file mode 100644 index 00000000..3fbcaf92 --- /dev/null +++ b/frontend/apps/widget/src/components/MessageInput.vue @@ -0,0 +1,283 @@ + + + diff --git a/frontend/apps/widget/src/components/MessageInputActions.vue b/frontend/apps/widget/src/components/MessageInputActions.vue new file mode 100644 index 00000000..72734c20 --- /dev/null +++ b/frontend/apps/widget/src/components/MessageInputActions.vue @@ -0,0 +1,106 @@ + + + diff --git a/frontend/apps/widget/src/components/MessagesList.vue b/frontend/apps/widget/src/components/MessagesList.vue new file mode 100644 index 00000000..af06cd04 --- /dev/null +++ b/frontend/apps/widget/src/components/MessagesList.vue @@ -0,0 +1,90 @@ + + + diff --git a/frontend/apps/widget/src/components/NoticeBanner.vue b/frontend/apps/widget/src/components/NoticeBanner.vue new file mode 100644 index 00000000..8c2e4734 --- /dev/null +++ b/frontend/apps/widget/src/components/NoticeBanner.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/apps/widget/src/components/PreChatForm.vue b/frontend/apps/widget/src/components/PreChatForm.vue new file mode 100644 index 00000000..6a360063 --- /dev/null +++ b/frontend/apps/widget/src/components/PreChatForm.vue @@ -0,0 +1,313 @@ + + + diff --git a/frontend/apps/widget/src/components/RecentConversationCard.vue b/frontend/apps/widget/src/components/RecentConversationCard.vue new file mode 100644 index 00000000..b6207d58 --- /dev/null +++ b/frontend/apps/widget/src/components/RecentConversationCard.vue @@ -0,0 +1,68 @@ + + + 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 new file mode 100644 index 00000000..a42259e4 --- /dev/null +++ b/frontend/apps/widget/src/components/WidgetError.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/apps/widget/src/components/preChatFormSchema.js b/frontend/apps/widget/src/components/preChatFormSchema.js new file mode 100644 index 00000000..8bb87052 --- /dev/null +++ b/frontend/apps/widget/src/components/preChatFormSchema.js @@ -0,0 +1,81 @@ +import { z } from 'zod' + +export const createPreChatFormSchema = (t, fields = []) => { + const schemaFields = {} + + fields + .filter(field => field.enabled) + .forEach(field => { + let fieldSchema + + switch (field.type) { + case 'email': + fieldSchema = z.string().email({ + message: t('globals.messages.invalidEmail') + }) + break + + case 'number': + fieldSchema = z.coerce.number({ + invalid_type_error: t('globals.messages.invalid', { name: field.label }) + }) + break + + case 'checkbox': + fieldSchema = z.boolean().default(false) + break + + case 'date': + fieldSchema = z.string().regex(/^(\d{4}-\d{2}-\d{2}|)$/, { + message: t('globals.messages.invalid', { name: field.label }) + }) + break + + case 'link': + fieldSchema = z.string().refine((val) => val === '' || z.string().url().safeParse(val).success, { + message: t('globals.messages.invalid', { name: t('globals.terms.url').toLowerCase() }) + }) + break + + case 'text': + case 'list': + default: + fieldSchema = z.string().max(1000, { + message: t('globals.messages.maxLength', { max: 1000 }) + }) + } + + if (field.required && field.type !== 'checkbox') { + fieldSchema = fieldSchema.min(1, { + message: t('globals.messages.required', { name: field.label || field.key }) + }) + } else if (field.type !== 'checkbox') { + fieldSchema = fieldSchema.optional() + } + + schemaFields[field.key] = fieldSchema + }) + + return z.object(schemaFields) +} + +export const createVisitorInfoSchema = (t, requireContactInfo) => { + const baseFields = [ + { + key: 'name', + type: 'text', + label: t('globals.terms.name'), + required: requireContactInfo === 'required', + enabled: true + }, + { + key: 'email', + type: 'email', + label: t('globals.terms.email'), + required: requireContactInfo === 'required', + enabled: true + } + ] + + return createPreChatFormSchema(t, baseFields) +} \ No newline at end of file diff --git a/frontend/apps/widget/src/composables/useBusinessHours.js b/frontend/apps/widget/src/composables/useBusinessHours.js new file mode 100644 index 00000000..05056ecf --- /dev/null +++ b/frontend/apps/widget/src/composables/useBusinessHours.js @@ -0,0 +1,241 @@ +import { format, isToday, isTomorrow, addDays, setHours, setMinutes } from 'date-fns' +import { useI18n } from 'vue-i18n' + +/** + * Business hours composable providing generic business hours utilities. + */ +export function useBusinessHours() { + const { t } = useI18n() + + /** + * 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 = [ + t('globals.days.sunday'), + t('globals.days.monday'), + t('globals.days.tuesday'), + t('globals.days.wednesday'), + t('globals.days.thursday'), + t('globals.days.friday'), + t('globals.days.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 t('globals.messages.backTodayAt', { time: format(nextWorkingTime, 'h:mm a') }) + } else if (isTomorrow(nextWorkingTime)) { + return t('globals.messages.backTomorrowAt', { time: format(nextWorkingTime, 'h:mm a') }) + } else { + return t('globals.messages.backOnDayAt', { + day: format(nextWorkingTime, 'EEEE'), + time: 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 = t('globals.messages.wellBeBack', { when: formatNextWorkingTime(nextWorkingTime) }) + } else { + status = t('globals.messages.currentlyOffline') + } + } + + 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 new file mode 100644 index 00000000..bde22f57 --- /dev/null +++ b/frontend/apps/widget/src/layouts/MainLayout.vue @@ -0,0 +1,63 @@ + + + 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 new file mode 100644 index 00000000..2e58eb19 --- /dev/null +++ b/frontend/apps/widget/src/main.js @@ -0,0 +1,67 @@ +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' + +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') + + // Signal to parent that Vue app is ready + if (window.parent !== window) { + window.parent.postMessage({ type: 'VUE_APP_READY' }, '*') + } + } catch (error) { + console.error('Error initializing widget:', error) + const app = createApp(App) + const pinia = createPinia() + app.use(pinia) + app.mount('#app') + + // Signal to parent that Vue app is ready (even on error) + if (window.parent !== window) { + window.parent.postMessage({ type: 'VUE_APP_READY' }, '*') + } + } +} + +initWidget() diff --git a/frontend/apps/widget/src/router/index.js b/frontend/apps/widget/src/router/index.js new file mode 100644 index 00000000..d93e66fa --- /dev/null +++ b/frontend/apps/widget/src/router/index.js @@ -0,0 +1,17 @@ +import { createRouter, createWebHistory } from 'vue-router' +import MainLayout from '@widget/layouts/MainLayout.vue' + +const routes = [ + { + path: '/', + component: MainLayout, + }, + +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +export default router diff --git a/frontend/apps/widget/src/store/chat.js b/frontend/apps/widget/src/store/chat.js new file mode 100644 index 00000000..1df8483a --- /dev/null +++ b/frontend/apps/widget/src/store/chat.js @@ -0,0 +1,288 @@ +import { defineStore } from 'pinia' +import { ref, computed, reactive } from 'vue' +import api from '../api/index.js' +import MessageCache from '@main/utils/conversation-message-cache.js' +import { useUserStore } from './user.js' + +export const useChatStore = defineStore('chat', () => { + const userStore = useUserStore() + // State + const isTyping = ref(false) + const currentConversation = ref({}) + const conversations = ref(null) + // Conversation messages cache, evict old conversation messages after 50 conversations. + const messageCache = reactive(new MessageCache(50)) + const isLoadingConversations = ref(false) + const isLoadingConversation = ref(false) + // Reactivity trigger for message cache changes this is easier than making the whole messageCache reactive. + const messageCacheVersion = ref(0) + + // Getters + const getCurrentConversationMessages = computed(() => { + messageCacheVersion.value // Force reactivity tracking + const convId = currentConversation.value?.uuid + if (!convId) return [] + const messages = messageCache.getAllPagesMessages(convId) + // Filter out continuity email messages + return messages.filter(msg => !msg.meta?.continuity_email) + }) + const hasConversations = computed(() => conversations.value?.length > 0) + const getConversations = computed(() => { + // Sort by `last_message.created_at` descending. + if (conversations.value) { + return conversations.value.sort((a, b) => new Date(b.last_message.created_at) - new Date(a.last_message.created_at)) + } + return [] + }) + + 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) + 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 addPendingMessage = (conversationUUID, messageText, authorType, authorId, files = []) => { + // 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: files.length > 0 ? 'uploading' : 'sending', + created_at: new Date().toISOString() + } + messageCache.addMessage(conversationUUID, pendingMessage) + messageCacheVersion.value++ // Trigger reactivity + + // Update conversations list with pending message + updateConversationListLastMessage(conversationUUID, pendingMessage) + + // Auto-remove after 10 seconds if still has temp ID + const tempId = pendingMessage.uuid + setTimeout(() => { + const messages = messageCache.getAllPagesMessages(conversationUUID) + if (messages.some(msg => msg.uuid === tempId)) { + removeMessage(conversationUUID, tempId) + } + }, 10000) + + return pendingMessage.uuid + } + + 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 { + isLoadingConversation.value = true + const resp = await api.getChatConversation(conversationUUID) + setCurrentConversation(resp.data.data.conversation) + replaceMessages(resp.data.data.messages) + currentConversation.value = resp.data.data.conversation + 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 + } finally { + isLoadingConversation.value = false + } + return true + } + + const replaceMessages = (newMessages) => { + const convId = currentConversation.value?.uuid + if (!convId) return + if (Array.isArray(newMessages) && newMessages.length > 0) { + // Purge and then add messages. + 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 values. + messageCache.addMessages(convId, [], 1, 1) + messageCacheVersion.value++ // Trigger reactivity + } + + const setTypingStatus = (conversationUUID, status) => { + if (!conversationUUID) return + if (currentConversation.value?.uuid !== conversationUUID) { + return + } + isTyping.value = status + } + + const setCurrentConversation = (conversation) => { + if (conversation === null) { + conversation = {} + } + // Clear messages if conversation is null or empty. + if (!conversation) { + clearMessages() + } + currentConversation.value = conversation + } + + const fetchConversations = async () => { + // No session token means no conversations can be fetched simply return empty. + if (!userStore.userSessionToken) { + return + } + + // If conversations are already loaded and is an array, do not fetch again. + if (Array.isArray(conversations.value)) { + return + } + + try { + isLoadingConversations.value = true + const response = await api.getChatConversations() + conversations.value = response.data.data || [] + } catch (error) { + // On 401, clear session from user store. + if (error.response && error.response.status === 401) { + userStore.clearSessionToken() + conversations.value = null + return + } + } finally { + isLoadingConversations.value = false + } + } + + const updateCurrentConversationLastSeen = async () => { + const conversationUUID = currentConversation.value?.uuid + if (!conversationUUID) return + try { + await 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 + } + } + } catch (error) { + console.error('Error updating last seen:', error) + } + } + + const updateCurrentConversation = (conversationData) => { + // Only update if it's the current conversation + if (currentConversation.value?.uuid === conversationData.uuid) { + currentConversation.value = conversationData + } + + // Also update in conversations list if present + if (conversations.value && Array.isArray(conversations.value)) { + const index = conversations.value.findIndex(c => c.uuid === conversationData.uuid) + if (index >= 0) { + conversations.value[index] = { ...conversations.value[index], ...conversationData } + } + } + } + + const addConversationToList = (conversation) => { + conversation.unread_message_count = 0 + if (!conversations.value) { + conversations.value = [] + } + const existingIndex = conversations.value.findIndex(c => c.uuid === conversation.uuid) + if (existingIndex >= 0) { + conversations.value[existingIndex] = conversation + return + } + conversations.value.push(conversation) + } + + return { + // State + messageCache, + isTyping, + conversations, + currentConversation, + isLoadingConversations, + isLoadingConversation, + + // Getters + getCurrentConversationMessages, + hasConversations, + getConversations, + + // Actions + addMessageToConversation, + addPendingMessage, + replaceMessage, + removeMessage, + replaceMessages, + clearMessages, + setTypingStatus, + setCurrentConversation, + fetchConversations, + loadConversation, + updateCurrentConversationLastSeen, + updateConversationListLastMessage, + updateCurrentConversation, + addConversationToList + } +}) diff --git a/frontend/apps/widget/src/store/user.js b/frontend/apps/widget/src/store/user.js new file mode 100644 index 00000000..82320f8a --- /dev/null +++ b/frontend/apps/widget/src/store/user.js @@ -0,0 +1,58 @@ +import { defineStore } from 'pinia' +import { computed } from 'vue' +import { useStorage } from '@vueuse/core' +import { parseJWT } from '@shared-ui/utils/string' + +export const useUserStore = defineStore('user', () => { + const userSessionToken = useStorage('libredesk_session', "") + + const isVisitor = computed(() => { + const token = userSessionToken.value + // Token not set, assume visitor. + if (!token) return true + const jwt = parseJWT(token) + 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 firstName = computed(() => { + const token = userSessionToken.value + if (!token) return '' + const jwt = parseJWT(token) + return jwt.first_name || '' + }) + + const lastName = computed(() => { + const token = userSessionToken.value + if (!token) return '' + const jwt = parseJWT(token) + return jwt.last_name || '' + }) + + 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, + userID, + firstName, + lastName, + 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 new file mode 100644 index 00000000..e69e5120 --- /dev/null +++ b/frontend/apps/widget/src/store/widget.js @@ -0,0 +1,141 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export const useWidgetStore = defineStore('widget', () => { + // State + const isOpen = ref(false) + const currentView = ref('home') + const config = ref({}) + const isInChatView = ref(false) + const isMobileFullScreen = ref(false) + const isExpanded = ref(false) + const wasExpandedBeforeLeaving = ref(false) + + + // Getters + const isHomeView = computed(() => currentView.value === 'home') + const isChatView = computed(() => isInChatView.value) + const isMessagesView = computed(() => currentView.value === 'messages' && !isInChatView.value) + + // Actions + const toggleWidget = () => { + isOpen.value = !isOpen.value + isInChatView.value = false + } + + const setOpen = (open) => { + isOpen.value = open + } + + const closeWidget = () => { + // Clear expanded state memory when widget is closed + wasExpandedBeforeLeaving.value = false + + isOpen.value = false + currentView.value = 'home' + isInChatView.value = false + // Auto-collapse when closing widget + if (isExpanded.value) { + collapseWidget() + } + } + + const navigateToChat = () => { + currentView.value = 'messages' + isInChatView.value = true + // Restore expanded state if it was expanded before leaving + if (wasExpandedBeforeLeaving.value && !isMobileFullScreen.value) { + setTimeout(() => { + expandWidget() + }, 100) + } + } + + const navigateToMessages = () => { + // Remember expanded state before leaving chat view + wasExpandedBeforeLeaving.value = isExpanded.value + + currentView.value = 'messages' + isInChatView.value = false + // Auto-collapse when leaving chat view + if (isExpanded.value) { + collapseWidget() + } + } + + const navigateToHome = () => { + // Remember expanded state before leaving chat view + wasExpandedBeforeLeaving.value = isExpanded.value + + currentView.value = 'home' + isInChatView.value = false + // Auto-collapse when leaving chat view + if (isExpanded.value) { + collapseWidget() + } + } + + const updateConfig = (newConfig) => { + config.value = { ...newConfig } + } + + const setMobileFullScreen = (isMobile) => { + isMobileFullScreen.value = isMobile + } + + const toggleExpand = () => { + if (isExpanded.value) { + collapseWidget() + } else { + expandWidget() + } + } + + const expandWidget = () => { + if (!isMobileFullScreen.value) { + isExpanded.value = true + window.parent.postMessage({ type: 'EXPAND_WIDGET' }, '*') + } + } + + const collapseWidget = () => { + if (!isMobileFullScreen.value) { + isExpanded.value = false + window.parent.postMessage({ type: 'COLLAPSE_WIDGET' }, '*') + } + } + + const setExpanded = (expanded) => { + isExpanded.value = expanded + } + + return { + // State + isOpen, + currentView, + config, + isInChatView, + isMobileFullScreen, + isExpanded, + wasExpandedBeforeLeaving, + + // Getters + isHomeView, + isChatView, + isMessagesView, + + // Actions + toggleWidget, + setOpen, + closeWidget, + navigateToChat, + navigateToMessages, + navigateToHome, + updateConfig, + setMobileFullScreen, + toggleExpand, + expandWidget, + collapseWidget, + setExpanded, + } +}) diff --git a/frontend/apps/widget/src/views/ChatView.vue b/frontend/apps/widget/src/views/ChatView.vue new file mode 100644 index 00000000..39395327 --- /dev/null +++ b/frontend/apps/widget/src/views/ChatView.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/apps/widget/src/views/HomeView.vue b/frontend/apps/widget/src/views/HomeView.vue new file mode 100644 index 00000000..f8769e4d --- /dev/null +++ b/frontend/apps/widget/src/views/HomeView.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend/apps/widget/src/views/MessagesView.vue b/frontend/apps/widget/src/views/MessagesView.vue new file mode 100644 index 00000000..20fc5eef --- /dev/null +++ b/frontend/apps/widget/src/views/MessagesView.vue @@ -0,0 +1,67 @@ + + + diff --git a/frontend/apps/widget/src/websocket.js b/frontend/apps/widget/src/websocket.js new file mode 100644 index 00000000..5153d52a --- /dev/null +++ b/frontend/apps/widget/src/websocket.js @@ -0,0 +1,262 @@ +// Widget WebSocket message types (matching backend constants) +import { useChatStore } from './store/chat.js' + +export const WS_EVENT = { + JOIN: 'join', + MESSAGE: 'message', + TYPING: 'typing', + ERROR: 'error', + NEW_MESSAGE: 'new_message', + STATUS: 'status', + JOINED: 'joined', + PONG: 'pong', + CONVERSATION_UPDATE: 'conversation_update', +} + +export class WidgetWebSocketClient { + constructor() { + this.socket = null + this.reconnectInterval = 1000 + this.maxReconnectInterval = 30000 + this.reconnectAttempts = 0 + this.maxReconnectAttempts = 50 + this.isReconnecting = false + this.manualClose = false + this.pingInterval = null + this.lastPong = Date.now() + this.jwt = null + this.inboxId = null + } + + init (jwt, inboxId) { + this.manualClose = false + this.jwt = jwt + this.inboxId = inboxId + this.connect() + this.setupNetworkListeners() + } + + connect () { + if (this.isReconnecting || this.manualClose) return + + try { + this.socket = new WebSocket('/widget/ws') + this.socket.addEventListener('open', this.handleOpen.bind(this)) + this.socket.addEventListener('message', this.handleMessage.bind(this)) + this.socket.addEventListener('error', this.handleError.bind(this)) + this.socket.addEventListener('close', this.handleClose.bind(this)) + } catch (error) { + console.error('Widget WebSocket connection error:', error) + this.reconnect() + } + } + + handleOpen () { + this.reconnectInterval = 1000 + const wasReconnecting = this.reconnectAttempts > 0 + this.reconnectAttempts = 0 + this.isReconnecting = false + this.lastPong = Date.now() + this.setupPing() + + // 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]: () => { + // Joined inbox. + }, + [WS_EVENT.PONG]: () => { + this.lastPong = Date.now() + }, + [WS_EVENT.NEW_MESSAGE]: () => { + if (data.data) { + chatStore.addMessageToConversation(data.data.conversation_uuid, data.data) + } + }, + [WS_EVENT.ERROR]: () => { + console.error('Widget WebSocket error:', data.data) + }, + [WS_EVENT.TYPING]: () => { + if (data.data && data.data.is_typing !== undefined) { + chatStore.setTypingStatus(data.data.conversation_uuid, data.data.is_typing) + } + }, + [WS_EVENT.CONVERSATION_UPDATE]: () => { + if (data.data && data.data.conversation) { + chatStore.updateCurrentConversation(data.data.conversation) + } + } + } + const handler = handlers[data.type] + if (handler) { + handler() + } else { + console.warn(`Unknown widget websocket event: ${data.type}`) + } + } catch (error) { + console.error('Widget message handling error:', error) + } + } + + handleError (event) { + console.error('Widget WebSocket error:', event) + this.reconnect() + } + + handleClose () { + this.clearPing() + if (!this.manualClose) { + this.reconnect() + } + } + + reconnect () { + if (this.isReconnecting || this.reconnectAttempts >= this.maxReconnectAttempts) return + + this.isReconnecting = true + this.reconnectAttempts++ + + setTimeout(() => { + this.isReconnecting = false + this.connect() + this.reconnectInterval = Math.min(this.reconnectInterval * 1.5, this.maxReconnectInterval) + }, this.reconnectInterval) + } + + setupNetworkListeners () { + window.addEventListener('online', () => { + if (this.socket?.readyState !== WebSocket.OPEN) { + this.reconnectInterval = 1000 + this.reconnect() + } + }) + + window.addEventListener('focus', () => { + if (this.socket?.readyState !== WebSocket.OPEN) { + this.reconnect() + } + }) + } + + setupPing () { + this.clearPing() + this.pingInterval = setInterval(() => { + if (this.socket?.readyState === WebSocket.OPEN) { + try { + this.socket.send(JSON.stringify({ + type: 'ping', + jwt: this.jwt, + inbox_id: this.inboxId ? parseInt(this.inboxId, 10) : null + })) + if (Date.now() - this.lastPong > 60000) { + console.warn('No pong received in 60 seconds, closing widget connection') + this.socket.close() + } + } catch (e) { + console.error('Widget ping error:', e) + this.reconnect() + } + } + }, 5000) + } + + clearPing () { + if (this.pingInterval) { + clearInterval(this.pingInterval) + this.pingInterval = null + } + } + + joinInbox () { + if (!this.inboxId || !this.jwt) { + console.error('Cannot join inbox: missing inbox_id or JWT') + return + } + + const joinMessage = { + type: WS_EVENT.JOIN, + jwt: this.jwt, + data: { + inbox_id: parseInt(this.inboxId, 10) + } + } + + this.send(joinMessage) + } + + // Resync current conversation after reconnection to catch any missed messages. + resyncCurrentConversation () { + const chatStore = useChatStore() + const currentConversationUUID = chatStore.currentConversation?.uuid + if (currentConversationUUID) { + chatStore.loadConversation(currentConversationUUID) + } + } + + sendTyping (isTyping = true, conversationUUID = null) { + const typingMessage = { + type: WS_EVENT.TYPING, + jwt: this.jwt, + data: { + conversation_uuid: conversationUUID, + is_typing: isTyping + } + } + this.send(typingMessage) + } + + send (message) { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify(message)) + } else { + console.warn('Widget WebSocket is not open. Message not sent:', message) + } + } + + close () { + this.manualClose = true + this.clearPing() + if (this.socket) { + this.socket.close() + } + } +} + +let widgetWSClient + +export function initWidgetWS (jwt, inboxId) { + if (!widgetWSClient) { + widgetWSClient = new WidgetWebSocketClient() + widgetWSClient.init(jwt, inboxId) + } else { + // Update JWT and inbox_id and rejoin if connection exists + widgetWSClient.jwt = jwt + widgetWSClient.inboxId = inboxId + if (widgetWSClient.socket?.readyState === WebSocket.OPEN) { + widgetWSClient.joinInbox() + } else { + // If connection is not open, reconnect + widgetWSClient.init(jwt, inboxId) + } + } + return widgetWSClient +} + +export const sendWidgetMessage = message => widgetWSClient?.send(message) +export const sendWidgetTyping = (isTyping = true, conversationUUID = null) => widgetWSClient?.sendTyping(isTyping, conversationUUID) +export const closeWidgetWebSocket = () => widgetWSClient?.close() +export const reOpenWidgetWebSocket = () => widgetWSClient?.reOpen() diff --git a/frontend/components.json b/frontend/components.json index 38566e41..5115309b 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -4,12 +4,12 @@ "typescript": false, "tailwind": { "config": "tailwind.config.js", - "css": "src/assets/styles/main.scss", + "css": "shared-ui/assets/styles/main.scss", "baseColor": "gray", "cssVariables": true }, "aliases": { - "components": "@/components", - "utils": "@/lib/utils" + "components": "@shared-ui/components", + "utils": "@shared-ui/lib/utils" } } diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json index 5a1f2d22..31f739b7 100644 --- a/frontend/jsconfig.json +++ b/frontend/jsconfig.json @@ -1,7 +1,10 @@ { "compilerOptions": { + "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@main/*": ["apps/main/src/*"], + "@widget/*": ["apps/widget/src/*"], + "@shared-ui/*": ["shared-ui/*"] } }, "exclude": ["node_modules", "dist"] diff --git a/frontend/package.json b/frontend/package.json index a2612c29..6e80f268 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,10 @@ "type": "module", "scripts": { "dev": "pnpm exec vite", - "build": "vite build", + "dev:main": "pnpm exec vite --mode main", + "dev:widget": "pnpm exec vite --mode widget", + "build:main": "vite build --mode main", + "build:widget": "vite build --mode widget", "preview": "vite preview", "test": "vitest", "test:run": "vitest run", @@ -18,6 +21,7 @@ }, "dependencies": { "@codemirror/lang-html": "^6.4.9", + "@codemirror/lang-javascript": "^6.2.4", "@codemirror/theme-one-dark": "^6.1.3", "@formkit/auto-animate": "^0.8.2", "@internationalized/date": "^3.5.5", @@ -45,7 +49,7 @@ "clsx": "^2.1.1", "codemirror": "^6.0.2", "date-fns": "^3.6.0", - "lucide-vue-next": "^0.378.0", + "lucide-vue-next": "^0.525.0", "mitt": "^3.0.1", "pinia": "^2.1.7", "qs": "^6.14.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 32b24cab..b725656a 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@codemirror/lang-html': specifier: ^6.4.9 version: 6.4.9 + '@codemirror/lang-javascript': + specifier: ^6.2.4 + version: 6.2.4 '@codemirror/theme-one-dark': specifier: ^6.1.3 version: 6.1.3 @@ -93,8 +96,8 @@ importers: specifier: ^3.6.0 version: 3.6.0 lucide-vue-next: - specifier: ^0.378.0 - version: 0.378.0(vue@3.5.13(typescript@5.7.3)) + specifier: ^0.525.0 + version: 0.525.0(vue@3.5.13(typescript@5.7.3)) mitt: specifier: ^3.0.1 version: 3.0.1 @@ -2544,8 +2547,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lucide-vue-next@0.378.0: - resolution: {integrity: sha512-tz2IUhdOf1q0x1mPOTZEJZYfXVLreQorO2ax4M+CxGOTgCNgXH3cljIWWfJ4jUvxn5rbkFlGPbl9EIfIelZBRA==} + lucide-vue-next@0.525.0: + resolution: {integrity: sha512-Xf8+x8B2DrnGDV/rxylS+KBp2FIe6ljwDn2JsGTZZvXIfhmm/q+nv8RuGO1OyoMjOVkkz7CqtUqJfwtFPRbB2w==} peerDependencies: vue: '>=3.0.1' @@ -6103,7 +6106,7 @@ snapshots: lru-cache@10.4.3: {} - lucide-vue-next@0.378.0(vue@3.5.13(typescript@5.7.3)): + lucide-vue-next@0.525.0(vue@3.5.13(typescript@5.7.3)): dependencies: vue: 3.5.13(typescript@5.7.3) diff --git a/frontend/src/assets/styles/main.scss b/frontend/shared-ui/assets/styles/main.scss similarity index 100% rename from frontend/src/assets/styles/main.scss rename to frontend/shared-ui/assets/styles/main.scss 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/src/components/ui/accordion/Accordion.vue b/frontend/shared-ui/components/ui/accordion/Accordion.vue similarity index 100% rename from frontend/src/components/ui/accordion/Accordion.vue rename to frontend/shared-ui/components/ui/accordion/Accordion.vue diff --git a/frontend/src/components/ui/accordion/AccordionContent.vue b/frontend/shared-ui/components/ui/accordion/AccordionContent.vue similarity index 94% rename from frontend/src/components/ui/accordion/AccordionContent.vue rename to frontend/shared-ui/components/ui/accordion/AccordionContent.vue index 99f5b4a4..bfd11163 100644 --- a/frontend/src/components/ui/accordion/AccordionContent.vue +++ b/frontend/shared-ui/components/ui/accordion/AccordionContent.vue @@ -1,7 +1,7 @@ + + diff --git a/frontend/shared-ui/components/ui/context-menu/ContextMenuCheckboxItem.vue b/frontend/shared-ui/components/ui/context-menu/ContextMenuCheckboxItem.vue new file mode 100644 index 00000000..9a472089 --- /dev/null +++ b/frontend/shared-ui/components/ui/context-menu/ContextMenuCheckboxItem.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/shared-ui/components/ui/context-menu/ContextMenuContent.vue b/frontend/shared-ui/components/ui/context-menu/ContextMenuContent.vue new file mode 100644 index 00000000..24490d69 --- /dev/null +++ b/frontend/shared-ui/components/ui/context-menu/ContextMenuContent.vue @@ -0,0 +1,54 @@ + + + diff --git a/frontend/shared-ui/components/ui/context-menu/ContextMenuGroup.vue b/frontend/shared-ui/components/ui/context-menu/ContextMenuGroup.vue new file mode 100644 index 00000000..1bc511d4 --- /dev/null +++ b/frontend/shared-ui/components/ui/context-menu/ContextMenuGroup.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/shared-ui/components/ui/context-menu/ContextMenuItem.vue b/frontend/shared-ui/components/ui/context-menu/ContextMenuItem.vue new file mode 100644 index 00000000..08abe67a --- /dev/null +++ b/frontend/shared-ui/components/ui/context-menu/ContextMenuItem.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/shared-ui/components/ui/context-menu/ContextMenuLabel.vue b/frontend/shared-ui/components/ui/context-menu/ContextMenuLabel.vue new file mode 100644 index 00000000..7d88c9c7 --- /dev/null +++ b/frontend/shared-ui/components/ui/context-menu/ContextMenuLabel.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/shared-ui/components/ui/context-menu/ContextMenuPortal.vue b/frontend/shared-ui/components/ui/context-menu/ContextMenuPortal.vue new file mode 100644 index 00000000..e4ae0138 --- /dev/null +++ b/frontend/shared-ui/components/ui/context-menu/ContextMenuPortal.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/shared-ui/components/ui/context-menu/ContextMenuRadioGroup.vue b/frontend/shared-ui/components/ui/context-menu/ContextMenuRadioGroup.vue new file mode 100644 index 00000000..7f88137b --- /dev/null +++ b/frontend/shared-ui/components/ui/context-menu/ContextMenuRadioGroup.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/shared-ui/components/ui/context-menu/ContextMenuRadioItem.vue b/frontend/shared-ui/components/ui/context-menu/ContextMenuRadioItem.vue new file mode 100644 index 00000000..cc680d95 --- /dev/null +++ b/frontend/shared-ui/components/ui/context-menu/ContextMenuRadioItem.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/shared-ui/components/ui/context-menu/ContextMenuSeparator.vue b/frontend/shared-ui/components/ui/context-menu/ContextMenuSeparator.vue new file mode 100644 index 00000000..bfa4a199 --- /dev/null +++ b/frontend/shared-ui/components/ui/context-menu/ContextMenuSeparator.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/shared-ui/components/ui/context-menu/ContextMenuShortcut.vue b/frontend/shared-ui/components/ui/context-menu/ContextMenuShortcut.vue new file mode 100644 index 00000000..a8b2bfb5 --- /dev/null +++ b/frontend/shared-ui/components/ui/context-menu/ContextMenuShortcut.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/shared-ui/components/ui/context-menu/ContextMenuSub.vue b/frontend/shared-ui/components/ui/context-menu/ContextMenuSub.vue new file mode 100644 index 00000000..278d0378 --- /dev/null +++ b/frontend/shared-ui/components/ui/context-menu/ContextMenuSub.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/shared-ui/components/ui/context-menu/ContextMenuSubContent.vue b/frontend/shared-ui/components/ui/context-menu/ContextMenuSubContent.vue new file mode 100644 index 00000000..bb588bce --- /dev/null +++ b/frontend/shared-ui/components/ui/context-menu/ContextMenuSubContent.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/shared-ui/components/ui/context-menu/ContextMenuSubTrigger.vue b/frontend/shared-ui/components/ui/context-menu/ContextMenuSubTrigger.vue new file mode 100644 index 00000000..3cb6f6f7 --- /dev/null +++ b/frontend/shared-ui/components/ui/context-menu/ContextMenuSubTrigger.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/shared-ui/components/ui/context-menu/ContextMenuTrigger.vue b/frontend/shared-ui/components/ui/context-menu/ContextMenuTrigger.vue new file mode 100644 index 00000000..c9bcf637 --- /dev/null +++ b/frontend/shared-ui/components/ui/context-menu/ContextMenuTrigger.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/shared-ui/components/ui/context-menu/index.js b/frontend/shared-ui/components/ui/context-menu/index.js new file mode 100644 index 00000000..947b5ba5 --- /dev/null +++ b/frontend/shared-ui/components/ui/context-menu/index.js @@ -0,0 +1,14 @@ +export { default as ContextMenu } from "./ContextMenu.vue"; +export { default as ContextMenuCheckboxItem } from "./ContextMenuCheckboxItem.vue"; +export { default as ContextMenuContent } from "./ContextMenuContent.vue"; +export { default as ContextMenuGroup } from "./ContextMenuGroup.vue"; +export { default as ContextMenuItem } from "./ContextMenuItem.vue"; +export { default as ContextMenuLabel } from "./ContextMenuLabel.vue"; +export { default as ContextMenuRadioGroup } from "./ContextMenuRadioGroup.vue"; +export { default as ContextMenuRadioItem } from "./ContextMenuRadioItem.vue"; +export { default as ContextMenuSeparator } from "./ContextMenuSeparator.vue"; +export { default as ContextMenuShortcut } from "./ContextMenuShortcut.vue"; +export { default as ContextMenuSub } from "./ContextMenuSub.vue"; +export { default as ContextMenuSubContent } from "./ContextMenuSubContent.vue"; +export { default as ContextMenuSubTrigger } from "./ContextMenuSubTrigger.vue"; +export { default as ContextMenuTrigger } from "./ContextMenuTrigger.vue"; diff --git a/frontend/src/components/ui/date-filter/DateFilter.vue b/frontend/shared-ui/components/ui/date-filter/DateFilter.vue similarity index 97% rename from frontend/src/components/ui/date-filter/DateFilter.vue rename to frontend/shared-ui/components/ui/date-filter/DateFilter.vue index 270317c9..58a71f27 100644 --- a/frontend/src/components/ui/date-filter/DateFilter.vue +++ b/frontend/shared-ui/components/ui/date-filter/DateFilter.vue @@ -84,8 +84,8 @@ import { SelectItem, SelectTrigger, SelectValue -} from '@/components/ui/select' -import { Input } from '@/components/ui/input' +} from '../select' +import { Input } from '../input' const { t } = useI18n() diff --git a/frontend/src/components/ui/date-filter/index.js b/frontend/shared-ui/components/ui/date-filter/index.js similarity index 100% rename from frontend/src/components/ui/date-filter/index.js rename to frontend/shared-ui/components/ui/date-filter/index.js diff --git a/frontend/src/components/ui/dialog/Dialog.vue b/frontend/shared-ui/components/ui/dialog/Dialog.vue similarity index 100% rename from frontend/src/components/ui/dialog/Dialog.vue rename to frontend/shared-ui/components/ui/dialog/Dialog.vue diff --git a/frontend/src/components/ui/dialog/DialogClose.vue b/frontend/shared-ui/components/ui/dialog/DialogClose.vue similarity index 100% rename from frontend/src/components/ui/dialog/DialogClose.vue rename to frontend/shared-ui/components/ui/dialog/DialogClose.vue diff --git a/frontend/src/components/ui/dialog/DialogContent.vue b/frontend/shared-ui/components/ui/dialog/DialogContent.vue similarity index 98% rename from frontend/src/components/ui/dialog/DialogContent.vue rename to frontend/shared-ui/components/ui/dialog/DialogContent.vue index 60a5dffb..9b4239e3 100644 --- a/frontend/src/components/ui/dialog/DialogContent.vue +++ b/frontend/shared-ui/components/ui/dialog/DialogContent.vue @@ -8,7 +8,7 @@ import { useForwardPropsEmits } from 'radix-vue' import { Cross2Icon } from '@radix-icons/vue' -import { cn } from '@/lib/utils' +import { cn } from '../../../lib/utils' const props = defineProps({ forceMount: { type: Boolean, required: false }, diff --git a/frontend/src/components/ui/dialog/DialogDescription.vue b/frontend/shared-ui/components/ui/dialog/DialogDescription.vue similarity index 93% rename from frontend/src/components/ui/dialog/DialogDescription.vue rename to frontend/shared-ui/components/ui/dialog/DialogDescription.vue index f9d4e82f..5a91be07 100644 --- a/frontend/src/components/ui/dialog/DialogDescription.vue +++ b/frontend/shared-ui/components/ui/dialog/DialogDescription.vue @@ -1,7 +1,7 @@ + + diff --git a/frontend/shared-ui/components/ui/resizable/ResizablePanelGroup.vue b/frontend/shared-ui/components/ui/resizable/ResizablePanelGroup.vue new file mode 100644 index 00000000..ee013d2e --- /dev/null +++ b/frontend/shared-ui/components/ui/resizable/ResizablePanelGroup.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/shared-ui/components/ui/resizable/index.js b/frontend/shared-ui/components/ui/resizable/index.js new file mode 100644 index 00000000..f19896cd --- /dev/null +++ b/frontend/shared-ui/components/ui/resizable/index.js @@ -0,0 +1,3 @@ +export { default as ResizableHandle } from "./ResizableHandle.vue"; +export { default as ResizablePanelGroup } from "./ResizablePanelGroup.vue"; +export { SplitterPanel as ResizablePanel } from "reka-ui"; diff --git a/frontend/src/components/ui/select/Select.vue b/frontend/shared-ui/components/ui/select/Select.vue similarity index 100% rename from frontend/src/components/ui/select/Select.vue rename to frontend/shared-ui/components/ui/select/Select.vue diff --git a/frontend/src/components/ui/select/SelectContent.vue b/frontend/shared-ui/components/ui/select/SelectContent.vue similarity index 98% rename from frontend/src/components/ui/select/SelectContent.vue rename to frontend/shared-ui/components/ui/select/SelectContent.vue index c9466d8e..6e200a34 100644 --- a/frontend/src/components/ui/select/SelectContent.vue +++ b/frontend/shared-ui/components/ui/select/SelectContent.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { SelectContent, SelectPortal, SelectViewport, useForwardPropsEmits } from 'radix-vue' import { SelectScrollDownButton, SelectScrollUpButton } from '.' -import { cn } from '@/lib/utils' +import { cn } from '../../../lib/utils' defineOptions({ inheritAttrs: false diff --git a/frontend/src/components/ui/select/SelectGroup.vue b/frontend/shared-ui/components/ui/select/SelectGroup.vue similarity index 92% rename from frontend/src/components/ui/select/SelectGroup.vue rename to frontend/shared-ui/components/ui/select/SelectGroup.vue index 8060d265..461df242 100644 --- a/frontend/src/components/ui/select/SelectGroup.vue +++ b/frontend/shared-ui/components/ui/select/SelectGroup.vue @@ -1,7 +1,7 @@ diff --git a/frontend/src/components/ui/sidebar/SidebarProvider.vue b/frontend/shared-ui/components/ui/sidebar/SidebarProvider.vue similarity index 98% rename from frontend/src/components/ui/sidebar/SidebarProvider.vue rename to frontend/shared-ui/components/ui/sidebar/SidebarProvider.vue index 069416cb..fa4310de 100644 --- a/frontend/src/components/ui/sidebar/SidebarProvider.vue +++ b/frontend/shared-ui/components/ui/sidebar/SidebarProvider.vue @@ -2,7 +2,7 @@ import { useEventListener, useMediaQuery, useVModel } from '@vueuse/core'; import { TooltipProvider } from 'reka-ui'; import { computed, ref } from 'vue'; -import { cn } from '@/lib/utils'; +import { cn } from '../../../lib/utils'; import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, diff --git a/frontend/src/components/ui/sidebar/SidebarRail.vue b/frontend/shared-ui/components/ui/sidebar/SidebarRail.vue similarity index 96% rename from frontend/src/components/ui/sidebar/SidebarRail.vue rename to frontend/shared-ui/components/ui/sidebar/SidebarRail.vue index da9f8345..dab6eab4 100644 --- a/frontend/src/components/ui/sidebar/SidebarRail.vue +++ b/frontend/shared-ui/components/ui/sidebar/SidebarRail.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/components/ui/spinner/index.js b/frontend/shared-ui/components/ui/spinner/index.js similarity index 100% rename from frontend/src/components/ui/spinner/index.js rename to frontend/shared-ui/components/ui/spinner/index.js diff --git a/frontend/src/components/ui/stepper/Stepper.vue b/frontend/shared-ui/components/ui/stepper/Stepper.vue similarity index 95% rename from frontend/src/components/ui/stepper/Stepper.vue rename to frontend/shared-ui/components/ui/stepper/Stepper.vue index fc242a11..917d7b60 100644 --- a/frontend/src/components/ui/stepper/Stepper.vue +++ b/frontend/shared-ui/components/ui/stepper/Stepper.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { StepperRoot, useForwardPropsEmits } from 'radix-vue' -import { cn } from '@/lib/utils' +import { cn } from '../../../lib/utils' const props = defineProps({ defaultValue: { type: Number, required: false }, diff --git a/frontend/src/components/ui/stepper/StepperDescription.vue b/frontend/shared-ui/components/ui/stepper/StepperDescription.vue similarity index 94% rename from frontend/src/components/ui/stepper/StepperDescription.vue rename to frontend/shared-ui/components/ui/stepper/StepperDescription.vue index c4d89525..3afa3bbe 100644 --- a/frontend/src/components/ui/stepper/StepperDescription.vue +++ b/frontend/shared-ui/components/ui/stepper/StepperDescription.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { StepperDescription, useForwardProps } from 'radix-vue' -import { cn } from '@/lib/utils' +import { cn } from '../../../lib/utils' const props = defineProps({ asChild: { type: Boolean, required: false }, diff --git a/frontend/src/components/ui/stepper/StepperIndicator.vue b/frontend/shared-ui/components/ui/stepper/StepperIndicator.vue similarity index 96% rename from frontend/src/components/ui/stepper/StepperIndicator.vue rename to frontend/shared-ui/components/ui/stepper/StepperIndicator.vue index ce279d4e..c9ae97f4 100644 --- a/frontend/src/components/ui/stepper/StepperIndicator.vue +++ b/frontend/shared-ui/components/ui/stepper/StepperIndicator.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { StepperIndicator, useForwardProps } from 'radix-vue' -import { cn } from '@/lib/utils' +import { cn } from '../../../lib/utils' const props = defineProps({ asChild: { type: Boolean, required: false }, diff --git a/frontend/src/components/ui/stepper/StepperItem.vue b/frontend/shared-ui/components/ui/stepper/StepperItem.vue similarity index 95% rename from frontend/src/components/ui/stepper/StepperItem.vue rename to frontend/shared-ui/components/ui/stepper/StepperItem.vue index cbd2ab56..19164688 100644 --- a/frontend/src/components/ui/stepper/StepperItem.vue +++ b/frontend/shared-ui/components/ui/stepper/StepperItem.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { StepperItem, useForwardProps } from 'radix-vue' -import { cn } from '@/lib/utils' +import { cn } from '../../../lib/utils' const props = defineProps({ step: { type: Number, required: true }, diff --git a/frontend/src/components/ui/stepper/StepperSeparator.vue b/frontend/shared-ui/components/ui/stepper/StepperSeparator.vue similarity index 95% rename from frontend/src/components/ui/stepper/StepperSeparator.vue rename to frontend/shared-ui/components/ui/stepper/StepperSeparator.vue index c7b22ce4..60aff153 100644 --- a/frontend/src/components/ui/stepper/StepperSeparator.vue +++ b/frontend/shared-ui/components/ui/stepper/StepperSeparator.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { StepperSeparator, useForwardProps } from 'radix-vue' -import { cn } from '@/lib/utils' +import { cn } from '../../../lib/utils' const props = defineProps({ orientation: { type: String, required: false }, diff --git a/frontend/src/components/ui/stepper/StepperTitle.vue b/frontend/shared-ui/components/ui/stepper/StepperTitle.vue similarity index 93% rename from frontend/src/components/ui/stepper/StepperTitle.vue rename to frontend/shared-ui/components/ui/stepper/StepperTitle.vue index 80d4af7e..6c99737b 100644 --- a/frontend/src/components/ui/stepper/StepperTitle.vue +++ b/frontend/shared-ui/components/ui/stepper/StepperTitle.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { StepperTitle, useForwardProps } from 'radix-vue' -import { cn } from '@/lib/utils' +import { cn } from '../../../lib/utils' const props = defineProps({ asChild: { type: Boolean, required: false }, diff --git a/frontend/src/components/ui/stepper/StepperTrigger.vue b/frontend/shared-ui/components/ui/stepper/StepperTrigger.vue similarity index 94% rename from frontend/src/components/ui/stepper/StepperTrigger.vue rename to frontend/shared-ui/components/ui/stepper/StepperTrigger.vue index 0f9e4b89..936d9ee8 100644 --- a/frontend/src/components/ui/stepper/StepperTrigger.vue +++ b/frontend/shared-ui/components/ui/stepper/StepperTrigger.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { StepperTrigger, useForwardProps } from 'radix-vue' -import { cn } from '@/lib/utils' +import { cn } from '../../../lib/utils' const props = defineProps({ asChild: { type: Boolean, required: false }, diff --git a/frontend/src/components/ui/stepper/index.js b/frontend/shared-ui/components/ui/stepper/index.js similarity index 100% rename from frontend/src/components/ui/stepper/index.js rename to frontend/shared-ui/components/ui/stepper/index.js diff --git a/frontend/src/components/ui/switch/Switch.vue b/frontend/shared-ui/components/ui/switch/Switch.vue similarity index 97% rename from frontend/src/components/ui/switch/Switch.vue rename to frontend/shared-ui/components/ui/switch/Switch.vue index 766c14bb..257944f8 100644 --- a/frontend/src/components/ui/switch/Switch.vue +++ b/frontend/shared-ui/components/ui/switch/Switch.vue @@ -1,7 +1,7 @@ diff --git a/frontend/src/constants/websocket.js b/frontend/src/constants/websocket.js deleted file mode 100644 index aa4ede05..00000000 --- a/frontend/src/constants/websocket.js +++ /dev/null @@ -1,6 +0,0 @@ -export const WS_EVENT = { - NEW_MESSAGE: 'new_message', - MESSAGE_PROP_UPDATE: 'message_prop_update', - CONVERSATION_PROP_UPDATE: 'conversation_prop_update', - NEW_NOTIFICATION: 'new_notification', -} \ No newline at end of file diff --git a/frontend/src/views/admin/inbox/InboxView.vue b/frontend/src/views/admin/inbox/InboxView.vue deleted file mode 100644 index 4f794d1c..00000000 --- a/frontend/src/views/admin/inbox/InboxView.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 9ec0c34c..7ece7273 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -9,10 +9,9 @@ module.exports = { prefix: "", content: [ - './pages/**/*.{js,jsx,vue}', - './components/**/*.{js,jsx,vue}', - './app/**/*.{js,jsx,vue}', - './src/**/*.{js,jsx,vue}', + './apps/main/src/**/*.{js,ts,vue}', + './apps/widget/src/**/*.{js,ts,vue}', + './shared-ui/**/*.{js,ts,vue}', ], theme: { diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8ac88a9a..5da95bbf 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,57 +1,89 @@ -import { fileURLToPath, URL } from 'node:url' +import { fileURLToPath } from 'url' +import path from 'path' import autoprefixer from 'autoprefixer' import tailwind from 'tailwindcss' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' -export default defineConfig({ - css: { - postcss: { - plugins: [tailwind(), autoprefixer()], - }, - }, - server: { - port: 8000, - proxy: { - '/api': { - target: 'http://127.0.0.1:9000', +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig(({ mode }) => { + // Determine which app to serve based on mode + const isWidget = mode === 'widget' + const appPath = isWidget ? 'apps/widget' : 'apps/main' + + return { + base: isWidget ? '/widget/' : '/', + css: { + postcss: { + plugins: [tailwind(), autoprefixer()], }, - '/logout': { - target: 'http://127.0.0.1:9000', + }, + root: mode === 'widget' ? path.resolve(__dirname, 'apps/widget') : path.resolve(__dirname, 'apps/main'), + server: { + port: isWidget ? 8001 : 8000, + proxy: { + '/api': { + target: 'http://127.0.0.1:9000', + }, + '/widget.js': { + target: 'http://127.0.0.1:9000', + }, + '/logout': { + target: 'http://127.0.0.1:9000', + }, + '/uploads': { + target: 'http://127.0.0.1:9000', + }, + '/ws': { + target: 'ws://127.0.0.1:9000', + ws: true, + }, + '/widget/ws': { + target: 'ws://127.0.0.1:9000', + ws: true, + } }, - '/uploads': { - target: 'http://127.0.0.1:9000', + }, + build: { + outDir: isWidget + ? path.resolve(__dirname, 'dist/widget') + : path.resolve(__dirname, 'dist/main'), + emptyOutDir: true, + rollupOptions: { + output: { + manualChunks: { + 'vue-vendor': ['vue', 'vue-router', 'pinia'], + 'radix': ['radix-vue', 'reka-ui'], + 'icons': ['lucide-vue-next', '@radix-icons/vue'], + 'utils': ['@vueuse/core', 'clsx', 'tailwind-merge', 'class-variance-authority'], + 'charts': ['@unovis/ts', '@unovis/vue'], + 'editor': [ + '@tiptap/vue-3', + '@tiptap/starter-kit', + '@tiptap/extension-image', + '@tiptap/extension-link', + '@tiptap/extension-placeholder', + '@tiptap/extension-table', + '@tiptap/extension-table-cell', + '@tiptap/extension-table-header', + '@tiptap/extension-table-row', + ], + 'forms': ['vee-validate', '@vee-validate/zod', 'zod'], + 'table': ['@tanstack/vue-table'], + 'misc': ['axios', 'date-fns', 'mitt', 'qs', 'vue-i18n'], + }, + }, }, - '/ws': { - target: 'ws://127.0.0.1:9000', - ws: true, + }, + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, `${appPath}/src`), + '@main': path.resolve(__dirname, 'apps/main/src'), + '@widget': path.resolve(__dirname, 'apps/widget/src'), + '@shared-ui': path.resolve(__dirname, 'shared-ui'), }, }, - }, - build: { - chunkSizeWarningLimit: 600, - rollupOptions: { - output: { - manualChunks: { - 'vue-vendor': ['vue', 'vue-router', 'pinia'], - 'radix': ['radix-vue', 'reka-ui'], - 'icons': ['lucide-vue-next', '@radix-icons/vue'], - 'utils': ['@vueuse/core', 'clsx', 'tailwind-merge', 'class-variance-authority'], - 'charts': ['@unovis/ts', '@unovis/vue'], - 'editor': ['@tiptap/vue-3', '@tiptap/starter-kit', '@tiptap/extension-image', '@tiptap/extension-link', '@tiptap/extension-placeholder', '@tiptap/extension-table', '@tiptap/extension-table-cell', '@tiptap/extension-table-header', '@tiptap/extension-table-row'], - 'forms': ['vee-validate', '@vee-validate/zod', 'zod'], - 'table': ['@tanstack/vue-table'], - 'misc': ['axios', 'date-fns', 'mitt', 'qs', 'vue-i18n'] - } - } - } - }, - plugins: [ - vue(), - ], - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)) - } - }, -}) \ No newline at end of file + } +}) diff --git a/go.mod b/go.mod index 4966c24c..4cd0101c 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/emersion/go-message v0.18.1 github.com/fasthttp/websocket v1.5.9 github.com/ferluci/fast-realip v1.0.1 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/gabriel-vasile/mimetype v1.4.11 github.com/google/uuid v1.6.0 github.com/jhillyerd/enmime v1.2.0 @@ -33,6 +34,7 @@ require ( github.com/rhnvrm/simples3 v0.10.1 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.10.0 + github.com/taion809/haikunator v0.0.0-20150324135039-4e414e676fd1 github.com/valyala/fasthttp v1.62.0 github.com/volatiletech/null/v9 v9.0.0 github.com/zerodha/fastglue v1.8.0 diff --git a/go.sum b/go.sum index b1b677f9..9dfa3808 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -167,6 +169,8 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/taion809/haikunator v0.0.0-20150324135039-4e414e676fd1 h1:Othyd9BE5Cc1J1FhMKEcZo732qwHjxU1+qFAy9qxyMI= +github.com/taion809/haikunator v0.0.0-20150324135039-4e414e676fd1/go.mod h1:YN59p3Qc11j61wypGtx03FU7uXUpuNfn29TmeCkg9ys= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.9.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w= diff --git a/i18n/en.json b/i18n/en.json index 2f8b62d2..eefa1292 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3,6 +3,7 @@ "_.name": "English (en)", "globals.terms.user": "User | Users", "globals.terms.contact": "Contact | Contacts", + "globals.terms.visitor": "Visitor | Visitors", "globals.terms.agent": "Agent | Agents", "globals.terms.team": "Team | Teams", "globals.terms.message": "Message | Messages", @@ -43,11 +44,13 @@ "globals.terms.inactive": "Inactive | Inactives", "globals.terms.integration": "Integration | Integrations", "globals.terms.content": "Content | Contents", + "globals.terms.fileUpload": "File Upload | File Uploads", "globals.terms.appRootURL": "App Root URL", "globals.terms.dashboard": "Dashboard | Dashboards", "globals.terms.tag": "Tag | Tags", "globals.terms.sla": "SLA | SLAs", - "globals.terms.slaPolicy": "SLA policy | SLA policies", + "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", "globals.terms.inbox": "Inbox | Inboxes", @@ -96,9 +99,22 @@ "globals.terms.mention": "Mention | Mentions", "globals.terms.pending": "Pending", "globals.terms.active": "Active", + "globals.terms.poor": "Poor", + "globals.terms.fair": "Fair", + "globals.terms.good": "Good", + "globals.terms.great": "Great", + "globals.terms.excellent": "Excellent", + "globals.terms.you": "You", + "globals.terms.brand": "Brand | Brands", + "globals.terms.someone": "Someone", + "globals.terms.expand": "Expand", + "globals.terms.collapse": "Collapse", + "globals.terms.home": "Home", + "globals.terms.copied": "Copied", "globals.terms.url": "URL | URLs", "globals.terms.rootURL": "Root URL", "globals.terms.key": "Key | Keys", + "globals.terms.label": "Label | Labels", "globals.terms.note": "Note | Notes", "globals.terms.ipAddress": "IP Address | IP Addresses", "globals.terms.alert": "Alert | Alerts", @@ -115,6 +131,7 @@ "globals.terms.myInbox": "My Inbox | My Inboxes", "globals.terms.teamInbox": "Team Inbox | Team Inboxes", "globals.terms.optional": "Optional | Optionals", + "globals.terms.placeholder": "Placeholder | Placeholders", "globals.terms.visibility": "Visibility | Visibilities", "globals.terms.privateNote": "Private note | Private notes", "globals.terms.automationRule": "Automation Rule | Automation Rules", @@ -150,6 +167,13 @@ "globals.terms.test": "Test | Tests", "globals.terms.confirmation": "Confirmation | Confirmations", "globals.terms.dialog": "Dialog | Dialogs", + "globals.terms.continue": "Continue", + "globals.terms.select": "Select", + "globals.terms.typeMessage": "Type your message...", + "globals.terms.tellUsMore": "Tell us more...", + "globals.terms.enterName": "Enter your name", + "globals.terms.enterEmail": "Enter your email address", + "globals.terms.helpUsServeYouBetter": "Help us serve you better by providing your contact information.", "globals.terms.modal": "Modal | Modals", "globals.terms.timezone": "Timezone | Timezones", "globals.terms.language": "Language | Languages", @@ -205,6 +229,8 @@ "globals.terms.recipient": "Recipient | Recipients", "globals.terms.tls": "TLS | TLSs", "globals.terms.credential": "Credential | Credentials", + "globals.terms.unAuthorized": "Unauthorized", + "globals.messages.noInternetConnection": "No internet connection", "globals.terms.tenantID": "Tenant ID", "globals.terms.copy": "Copy", "globals.messages.markAsUnread": "Mark as unread", @@ -212,6 +238,7 @@ "globals.messages.invalid": "Invalid {name}", "globals.messages.custom": "Custom {name}", "globals.messages.replying": "Replying", + "globals.messages.sessionExpired": "Session expired", "globals.messages.hoursSince": "Hours since", "globals.messages.hoursSinceCreated": "Hours since created", "globals.messages.hoursSinceFirstReply": "Hours since first reply", @@ -257,6 +284,7 @@ "globals.messages.revokedSuccessfully": "{name} revoked successfully", "globals.messages.errorRevoking": "Error revoking {name}", "globals.messages.generatedSuccessfully": "{name} generated successfully", + "globals.messages.uploading": "Uploading {name}", "globals.messages.connectedSuccessfully": "{name} connected successfully", "globals.messages.reconnectedSuccessfully": "{name} reconnected successfully", "globals.messages.errorConnecting": "Error connecting {name}", @@ -288,6 +316,7 @@ "globals.messages.type": "{name} type", "globals.messages.typeOf": "Type of {name}", "globals.messages.invalidEmailAddress": "Invalid email address", + "globals.messages.invalidEmail": "Invalid email address", "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}", @@ -298,6 +327,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", @@ -336,8 +366,24 @@ "globals.messages.import": "Import {name}", "globals.messages.apply": "Apply {name}", "globals.messages.reset": "Reset {name}", + "globals.messages.disabled": "{name} is disabled", "globals.messages.lastNItems": "Last {n} {name} | Last {n} {name}", "globals.messages.correctEmailErrors": "Please correct the email errors", + "globals.messages.addEmoji": "Add emoji", + "globals.messages.closeChat": "Close chat", + "globals.messages.currentlyOffline": "We are currently offline", + "globals.messages.sendUsMessage": "Send us a message", + "globals.messages.startNewConversation": "Start new conversation", + "globals.messages.backTodayAt": "today at {time}", + "globals.messages.backTomorrowAt": "tomorrow at {time}", + "globals.messages.backOnDayAt": "on {day} at {time}", + "globals.messages.wellBeBack": "We'll be back {when}", + "globals.messages.additionalFeedbackOptional": "Additional feedback (optional)", + "globals.messages.pleaseRateConversation": "Please rate this conversation:", + "globals.messages.submitting": "Submitting...", + "globals.messages.submitFeedback": "Submit feedback", + "globals.messages.thankYouFeedback": "✅ Thank you for your feedback!", + "globals.messages.sending": "Sending...", "globals.messages.additionalFeedback": "Additional feedback (optional)", "globals.messages.pleaseSelect": "Please select {name} before submitting", "globals.messages.poweredBy": "Powered by", @@ -350,15 +396,15 @@ "form.error.minmaxNumber": "Must be between {min} and {max}", "form.error.time.invalid": "Invalid time format (HH:mm)", "form.error.validUrl": "Invalid URL", - "user.resetPasswordTokenExpired": "Token is invalid or expired, please try again by requesting a new password reset link", + "user.resetPasswordTokenExpired": "Token is invalid or expired, Please try again by requesting a new password reset link", "user.userCannotDeleteSelf": "You cannot delete yourself", "user.userAlreadyLoggedIn": "User already logged in", "user.invalidEmailPassword": "Invalid email or password.", - "user.accountDisabled": "Your account is disabled, please contact administrator", + "user.accountDisabled": "Your account is disabled, Please contact administrator", "user.cannotDeleteSystemUser": "Cannot delete system user", "user.sameEmailAlreadyExists": "User with same email already exists", "user.errorGeneratingPasswordToken": "Error generating password token", - "media.fileSizeTooLarge": "File size too large, please upload a file less than {size} ", + "media.fileSizeTooLarge": "File size too large, Please upload a file less than {size} ", "media.fileTypeNotAllowed": "File type not allowed", "media.fileEmpty": "This file is 0 bytes, so it will not be attached.", "media.invalidOrExpiredURL": "Invalid or expired media URL", @@ -514,6 +560,102 @@ "admin.inbox.chooseChannel": "Choose a channel", "admin.inbox.configureChannel": "Configure channel", "admin.inbox.createEmailInbox": "Create Email Inbox", + "admin.inbox.livechatConfig": "Live chat configuration", + "admin.inbox.help.title": "Manage inboxes", + "admin.inbox.help.description": "Configure and manage different communication channels for customer interactions", + "admin.inbox.help.email": "Configure IMAP/SMTP settings to set up email support and receive customer emails", + "admin.inbox.help.livechat": "Create live chat widgets that can be embedded on your website for real-time customer support", + "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.prechat": "Pre-chat form", + "admin.inbox.livechat.tabs.users": "Users", + "admin.inbox.livechat.tabs.installation": "Installation", + "admin.inbox.livechat.installation.instructions.title": "Installation instructions", + "admin.inbox.livechat.installation.instructions.step1": "Copy the code snippet below", + "admin.inbox.livechat.installation.instructions.step2": "Paste it just before the closing `body` tag on every page where you want the live chat widget to appear", + "admin.inbox.livechat.installation.authenticated.title": "For authenticated users", + "admin.inbox.livechat.installation.authenticated.jwt.description": "To identify logged-in users, generate a JWT on your server with the following payload structure:", + "admin.inbox.livechat.installation.authenticated.implementation.description": "Then pass the signed JWT token to the widget initialization:", + "admin.inbox.livechat.installation.authenticated.secret.note": "Important: Sign the JWT with the secret key configured in the Security tab", + "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", + "admin.inbox.livechat.secretKey.description": "Set a secret key to secure the chat widget.", + "admin.inbox.livechat.launcher": "Launcher", + "admin.inbox.livechat.launcher.position": "Position", + "admin.inbox.livechat.launcher.position.left": "Left", + "admin.inbox.livechat.launcher.position.right": "Right", + "admin.inbox.livechat.launcher.logo": "Launcher logo", + "admin.inbox.livechat.launcher.spacing.side": "Side spacing", + "admin.inbox.livechat.launcher.spacing.side.description": "Distance from the side of the screen in pixels", + "admin.inbox.livechat.launcher.spacing.bottom": "Bottom spacing", + "admin.inbox.livechat.launcher.spacing.bottom.description": "Distance from the bottom of the screen in pixels", + "admin.inbox.livechat.messages": "Messages", + "admin.inbox.livechat.greetingMessage": "Greeting message", + "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", + "admin.inbox.livechat.showOfficeHoursAfterAssignment": "Show office hours after team assignment", + "admin.inbox.livechat.showOfficeHoursAfterAssignment.description": "Show office hours after conversation is assigned to a team", + "admin.inbox.livechat.showPoweredBy": "Show powered by", + "admin.inbox.livechat.showPoweredBy.description": "Show \"Powered by Libredesk\" in the chat widget", + "admin.inbox.livechat.noticeBanner": "Notice banner", + "admin.inbox.livechat.noticeBanner.enabled": "Enable notice banner", + "admin.inbox.livechat.noticeBanner.text": "Notice banner text", + "admin.inbox.livechat.colors": "Colors", + "admin.inbox.livechat.colors.primary": "Primary color", + "admin.inbox.livechat.colors.background": "Background color", + "admin.inbox.livechat.darkMode": "Dark mode", + "admin.inbox.livechat.darkMode.description": "Enable dark mode for the chat widget", + "admin.inbox.livechat.features": "Features", + "admin.inbox.livechat.features.fileUpload": "File upload", + "admin.inbox.livechat.features.fileUpload.description": "Allow users to upload files in chat", + "admin.inbox.livechat.features.emoji": "Emoji support", + "admin.inbox.livechat.features.emoji.description": "Allow users to use emojis in chat", + "admin.inbox.livechat.features.allowCloseConversation": "Allow close conversation", + "admin.inbox.livechat.features.allowCloseConversation.description": "Allow users to close their own conversations", + "admin.inbox.livechat.directToConversation": "Launch directly into conversation", + "admin.inbox.livechat.directToConversation.description": "Skip the home screen and open directly into the user's latest conversation, or a new conversation if none exists", + "admin.inbox.livechat.greetingMessage.variables": "Available variables: {'{{.FirstName}}'}, {'{{.LastName}}'}", + "admin.inbox.livechat.externalLinks": "External links", + "admin.inbox.livechat.externalLinks.add": "Add external link", + "admin.inbox.livechat.externalLinks.description": "Add helpful links that will be displayed in the chat widget", + "admin.inbox.livechat.trustedDomains": "Trusted domains", + "admin.inbox.livechat.trustedDomains.list": "Domain list", + "admin.inbox.livechat.trustedDomains.description": "Specify your trusted domains and subdomains, one per line. Use an asterisk wildcard to trust all subdomains: *.example.com. Leaving this field empty will allowing widget to be embedded on any domain.", + "admin.inbox.livechat.userSettings": "User settings", + "admin.inbox.livechat.userSettings.visitors": "Visitors", + "admin.inbox.livechat.userSettings.users": "Users", + "admin.inbox.livechat.startConversationButtonText": "Start conversation button text", + "admin.inbox.livechat.allowStartConversation": "Allow start conversation", + "admin.inbox.livechat.allowStartConversation.visitors.description": "Allow visitors to start new conversations", + "admin.inbox.livechat.allowStartConversation.users.description": "Allow users users to start new conversations", + "admin.inbox.livechat.preventMultipleConversations": "Prevent multiple conversations", + "admin.inbox.livechat.preventMultipleConversations.visitors.description": "Prevent visitors from starting multiple conversations simultaneously", + "admin.inbox.livechat.preventMultipleConversations.users.description": "Prevent users users from starting multiple conversations simultaneously", + "admin.inbox.livechat.preventReplyingToClosedConversations": "Prevent replying to closed conversations", + "admin.inbox.livechat.preventReplyingToClosedConversations.visitors.description": "Prevent visitors from replying to closed conversations", + "admin.inbox.livechat.preventReplyingToClosedConversations.users.description": "Prevent users users from replying to closed conversations", + "admin.inbox.livechat.prechatForm.enabled": "Enable pre-chat form", + "admin.inbox.livechat.prechatForm.enabled.description": "Show a form to collect information before chat starts", + "admin.inbox.livechat.prechatForm.title": "Form title", + "admin.inbox.livechat.prechatForm.title.description": "Title displayed above the pre-chat form", + "admin.inbox.livechat.prechatForm.fields": "Form fields", + "admin.inbox.livechat.prechatForm.addField": "Add field", + "admin.inbox.livechat.prechatForm.noFields": "No fields configured. Add fields from custom attributes.", + "admin.inbox.livechat.prechatForm.availableFields": "Available custom attributes", + "admin.inbox.livechat.conversationContinuity": "Conversation continuity email inbox", + "admin.inbox.livechat.conversationContinuity.description": "When contacts go offline, replies will be sent from this email inbox. The contacts can continue the same conversation by replying to the email or in the chat widget when they return to your site.", + "admin.inbox.livechat.continuityEmailFooter": "Reply directly to this email to continue the conversation.", + "admin.inbox.livechat.continuityEmailSubject": "New messages from {site_name}", "admin.inbox.oauth.chooseSetupMethod": "Choose setup method", "admin.inbox.oauth.selectConnectionMethod": "Select how you want to connect your email account", "admin.inbox.oauth.googleDescription": "Connect with Google Workspace or Gmail", @@ -682,7 +824,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", @@ -720,8 +861,16 @@ "contact.blockConfirm": "Are you sure you want to block this contact? They will no longer be able to interact with you.", "contact.unblockConfirm": "Are you sure you want to unblock this contact? They will be able to interact with you again.", "contact.alreadyExistsWithEmail": "Another contact with same email already exists", + "contact.identityNotVerified": "Identity not verified", "contact.notes.empty": "No notes yet", "contact.notes.help": "Add note for this contact to keep track of important information and conversations.", + "globals.days.sunday": "Sunday", + "globals.days.monday": "Monday", + "globals.days.tuesday": "Tuesday", + "globals.days.wednesday": "Wednesday", + "globals.days.thursday": "Thursday", + "globals.days.friday": "Friday", + "globals.days.saturday": "Saturday", "setup.completeYourSetup": "Complete your setup", "setup.createFirstInbox": "Create your first inbox", "setup.inviteTeammates": "Invite teammates", 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/conversation/continuity.go b/internal/conversation/continuity.go new file mode 100644 index 00000000..d35b6344 --- /dev/null +++ b/internal/conversation/continuity.go @@ -0,0 +1,294 @@ +package conversation + +import ( + "context" + "encoding/json" + "fmt" + "html" + "slices" + "strings" + "time" + + "github.com/abhinavxd/libredesk/internal/attachment" + "github.com/abhinavxd/libredesk/internal/conversation/models" + "github.com/abhinavxd/libredesk/internal/stringutil" + "github.com/volatiletech/null/v9" +) + +// RunContinuity starts a goroutine that sends continuity emails containing unread outgoing messages to contacts who have been offline for a configured duration. +func (m *Manager) RunContinuity(ctx context.Context) { + m.lo.Info("starting conversation continuity processor", "check_interval", m.continuityConfig.BatchCheckInterval) + + ticker := time.NewTicker(m.continuityConfig.BatchCheckInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := m.processContinuityEmails(); err != nil { + m.lo.Error("error processing continuity emails", "error", err) + } + } + } +} + +// processContinuityEmails finds offline livechat conversations and sends batched unread messages emails to contacts +func (m *Manager) processContinuityEmails() error { + var ( + offlineThresholdMinutes = int(m.continuityConfig.OfflineThreshold.Minutes()) + minEmailIntervalMinutes = int(m.continuityConfig.MinEmailInterval.Minutes()) + maxMessagesPerEmail = m.continuityConfig.MaxMessagesPerEmail + conversations []models.ContinuityConversation + ) + + m.lo.Debug("fetching offline conversations for continuity emails", "offline_threshold_minutes", offlineThresholdMinutes, "min_email_interval_minutes", minEmailIntervalMinutes) + + if err := m.q.GetOfflineLiveChatConversations.Select(&conversations, offlineThresholdMinutes, minEmailIntervalMinutes); err != nil { + return fmt.Errorf("error fetching offline conversations: %w", err) + } + + m.lo.Debug("fetched offline conversations for continuity emails", "count", len(conversations)) + + for _, conv := range conversations { + m.lo.Info("sending continuity email for conversation", "conversation_uuid", conv.UUID, "contact_email", conv.ContactEmail) + if err := m.sendContinuityEmail(conv, maxMessagesPerEmail); err != nil { + m.lo.Error("error sending continuity email", "conversation_uuid", conv.UUID, "error", err) + continue + } + } + + return nil +} + +// sendContinuityEmail sends a batched continuity email for a conversation +func (m *Manager) sendContinuityEmail(conv models.ContinuityConversation, maxMessages int) error { + var ( + message models.Message + cleanUp = false + ) + + if conv.ContactEmail.String == "" { + m.lo.Debug("no contact email for conversation, skipping continuity email", "conversation_uuid", conv.UUID) + return fmt.Errorf("no contact email for conversation") + } + + // Cleanup inserted message on failure + defer func() { + if cleanUp { + if _, delErr := m.q.DeleteMessage.Exec(message.ID, message.UUID); delErr != nil { + m.lo.Error("error cleaning up failed continuity message", + "error", delErr, + "message_id", message.ID, + "message_uuid", message.UUID, + "conversation_uuid", conv.UUID) + } + } + }() + + m.lo.Debug("fetching unread messages for continuity email", "conversation_uuid", conv.UUID, "contact_last_seen_at", conv.ContactLastSeenAt, "max_messages", maxMessages) + var unreadMessages []models.ContinuityUnreadMessage + if err := m.q.GetUnreadMessages.Select(&unreadMessages, conv.ID, conv.ContactLastSeenAt, maxMessages); err != nil { + return fmt.Errorf("error fetching unread messages: %w", err) + } + m.lo.Debug("fetched unread messages for continuity email", "conversation_uuid", conv.UUID, "unread_count", len(unreadMessages)) + + if len(unreadMessages) == 0 { + m.lo.Debug("no unread messages found for conversation, skipping continuity email", "conversation_uuid", conv.UUID) + return nil + } + + // Get linked email inbox + if !conv.LinkedEmailInboxID.Valid { + return fmt.Errorf("no linked email inbox configured for livechat inbox") + } + linkedEmailInbox, err := m.inboxStore.Get(conv.LinkedEmailInboxID.Int) + if err != nil { + return fmt.Errorf("error fetching linked email inbox: %w", err) + } + + // Build email content with all unread messages + emailContent := m.buildContinuityEmailContent(unreadMessages) + + // Collect attachments from all unread messages + attachments, err := m.collectAttachmentsFromMessages(unreadMessages) + if err != nil { + m.lo.Error("error collecting attachments from messages", "conversation_uuid", conv.UUID, "error", err) + return fmt.Errorf("error collecting attachments for continuity email: %w", err) + } + + // Generate email subject with site name, this subject is translated + siteName := "Support" + if siteNameJSON, err := m.settingsStore.Get("app.site_name"); err == nil { + siteName = strings.Trim(strings.TrimSpace(string(siteNameJSON)), "\"") + } + emailSubject := m.i18n.Ts("admin.inbox.livechat.continuityEmailSubject", "site_name", siteName) + + // Generate unique Message-ID for threading + sourceID, err := stringutil.GenerateEmailMessageID(conv.UUID, linkedEmailInbox.FromAddress()) + if err != nil { + return fmt.Errorf("error generating message ID: %w", err) + } + + // Get system user for sending the email + systemUser, err := m.userStore.GetSystemUser() + if err != nil { + return fmt.Errorf("error fetching system user: %w", err) + } + + metaJSON, err := json.Marshal(map[string]any{ + "continuity_email": true, + }) + if err != nil { + m.lo.Error("error marshalling continuity email meta", "error", err, "conversation_uuid", conv.UUID) + return fmt.Errorf("error marshalling continuity email meta: %w", err) + } + + // Create message for sending + message = models.Message{ + InboxID: conv.LinkedEmailInboxID.Int, + ConversationID: conv.ID, + ConversationUUID: conv.UUID, + SenderID: systemUser.ID, + Type: models.MessageOutgoing, + SenderType: models.SenderTypeAgent, + Status: models.MessageStatusSent, + Content: emailContent, + ContentType: models.ContentTypeHTML, + Private: false, + SourceID: null.StringFrom(sourceID), + MessageReceiverID: conv.ContactID, + From: linkedEmailInbox.FromAddress(), + To: []string{conv.ContactEmail.String}, + Subject: emailSubject, + Meta: metaJSON, + Attachments: attachments, + } + + // Set Reply-To header for conversation continuity + emailAddress, err := stringutil.ExtractEmail(linkedEmailInbox.FromAddress()) + if err == nil { + emailUserPart := strings.Split(emailAddress, "@") + if len(emailUserPart) == 2 { + message.Headers = map[string][]string{ + "Reply-To": {fmt.Sprintf("%s+%s@%s", emailUserPart[0], conv.UUID, emailUserPart[1])}, + } + } + } + + // Insert message into database + if err := m.InsertMessage(&message); err != nil { + return fmt.Errorf("error inserting continuity message: %w", err) + } + + // Get all message source IDs for References header and threading + references, err := m.GetMessageSourceIDs(conv.ID, 100) + if err != nil { + m.lo.Error("error fetching conversation source IDs for continuity email", "error", err) + references = []string{} + } + + // References is sorted in DESC i.e newest message first, so reverse it to keep the references in order. + slices.Reverse(references) + + // Filter out livechat references (ones without @) and keep only the last 20 + var filteredReferences []string + for _, ref := range references { + if strings.Contains(ref, "@") { + filteredReferences = append(filteredReferences, ref) + // Keep only the last 20 references, remove the first one if exceeding + if len(filteredReferences) > 20 { + filteredReferences = filteredReferences[1:] + } + } + } + message.References = filteredReferences + + // Set In-Reply-To if we have references + if len(filteredReferences) > 0 { + message.InReplyTo = filteredReferences[len(filteredReferences)-1] + } + + // Render message template + if err := m.RenderMessageInTemplate(linkedEmailInbox.Channel(), &message); err != nil { + // Clean up the inserted message on failure + cleanUp = true + m.lo.Error("error rendering email template for continuity email", "error", err, "message_id", message.ID, "message_uuid", message.UUID, "conversation_uuid", conv.UUID) + return fmt.Errorf("error rendering email template: %w", err) + } + + // Send the email + if err := linkedEmailInbox.Send(message); err != nil { + // Clean up the inserted message on failure + cleanUp = true + m.lo.Error("error sending continuity email", "error", err, "message_id", message.ID, "message_uuid", message.UUID, "conversation_uuid", conv.UUID) + return fmt.Errorf("error sending continuity email: %w", err) + } + + // Mark in DB that continuity email was sent now + if _, err := m.q.UpdateContinuityEmailTracking.Exec(conv.ID); err != nil { + m.lo.Error("error updating continuity email tracking", "conversation_uuid", conv.UUID, "error", err) + return fmt.Errorf("error updating continuity email tracking: %w", err) + } + + m.lo.Info("sent conversation continuity email", + "conversation_uuid", conv.UUID, + "contact_email", conv.ContactEmail, + "message_count", len(unreadMessages), + "linked_email_inbox_id", conv.LinkedEmailInboxID.Int) + + return nil +} + +// buildContinuityEmailContent creates email content with conversation summary and unread messages +func (m *Manager) buildContinuityEmailContent(unreadMessages []models.ContinuityUnreadMessage) string { + var content strings.Builder + + for _, msg := range unreadMessages { + // Get sender display name + senderName := "Agent" + if msg.SenderFirstName.Valid || msg.SenderLastName.Valid { + firstName := strings.TrimSpace(msg.SenderFirstName.String) + lastName := strings.TrimSpace(msg.SenderLastName.String) + fullName := strings.TrimSpace(firstName + " " + lastName) + if fullName != "" { + senderName = fullName + } + } + + // Format timestamp + timestamp := msg.CreatedAt.Format("Mon, Jan 2, 2006 at 3:04 PM") + + // Add message header with agent name and timestamp + content.WriteString(fmt.Sprintf("

%s %s

\n", + html.EscapeString(senderName), + html.EscapeString(timestamp))) + + // Add message content + content.WriteString(msg.Content) + content.WriteString("\n
\n") + } + + // Add footer with reply instructions, footer is translated + content.WriteString("
\n") + content.WriteString(fmt.Sprintf("

%s

\n", html.EscapeString(m.i18n.T("admin.inbox.livechat.continuityEmailFooter")))) + + return content.String() +} + +// collectAttachmentsFromMessages collects all attachments from unread messages for the continuity email +func (m *Manager) collectAttachmentsFromMessages(unreadMessages []models.ContinuityUnreadMessage) (attachment.Attachments, error) { + var allAttachments attachment.Attachments + + for _, msg := range unreadMessages { + msgAttachments, err := m.fetchMessageAttachments(msg.ID) + if err != nil { + m.lo.Error("error fetching attachments for message", "error", err, "message_id", msg.ID) + continue + } + allAttachments = append(allAttachments, msgAttachments...) + } + + return allAttachments, nil +} diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index d308cfb2..f6dc2a80 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -36,6 +36,7 @@ import ( wmodels "github.com/abhinavxd/libredesk/internal/webhook/models" "github.com/abhinavxd/libredesk/internal/ws" "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx/types" "github.com/knadh/go-i18n" "github.com/lib/pq" "github.com/volatiletech/null/v9" @@ -82,6 +83,24 @@ type Manager struct { closed bool closedMu sync.RWMutex wg sync.WaitGroup + continuityConfig ContinuityConfig +} + +// WidgetConversationView represents the conversation data for widget clients +type WidgetConversationView struct { + UUID string `json:"uuid"` + Status string `json:"status"` + Assignee interface{} `json:"assignee"` + BusinessHoursID *int `json:"business_hours_id,omitempty"` + WorkingHoursUTCOffset *int `json:"working_hours_utc_offset,omitempty"` +} + +// WidgetConversationResponse represents the full conversation response for widget with messages +type WidgetConversationResponse struct { + Conversation models.ChatConversation `json:"conversation"` + Messages []models.ChatMessage `json:"messages"` + BusinessHoursID *int `json:"business_hours_id,omitempty"` + WorkingHoursUTCOffset *int `json:"working_hours_utc_offset,omitempty"` } type slaStore interface { @@ -105,13 +124,18 @@ type teamStore interface { } type userStore interface { + Get(int, string, []string) (umodels.User, error) GetAgent(int, string) (umodels.User, error) + GetContact(int, string) (umodels.User, error) + GetVisitor(int) (umodels.User, error) GetSystemUser() (umodels.User, error) CreateContact(user *umodels.User) error } type mediaStore interface { GetBlob(name string) ([]byte, error) + GetURL(uuid, contentType, fileName string) string + GetSignedURL(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) @@ -126,10 +150,13 @@ type inboxStore interface { type settingsStore interface { GetAppRootURL() (string, error) + GetByPrefix(prefix string) (types.JSONText, error) + Get(key string) (types.JSONText, error) } type csatStore interface { Create(conversationID int) (csatModels.CSATResponse, error) + Get(uuid string) (csatModels.CSATResponse, error) MakePublicURL(appBaseURL, uuid string) string } @@ -137,12 +164,21 @@ type webhookStore interface { TriggerEvent(event wmodels.WebhookEvent, data any) } +// ContinuityConfig holds configuration for conversation continuity emails +type ContinuityConfig struct { + BatchCheckInterval time.Duration + OfflineThreshold time.Duration + MinEmailInterval time.Duration + MaxMessagesPerEmail int +} + // Opts holds the options for creating a new Manager. type Opts struct { DB *sqlx.DB Lo *logf.Logger OutgoingMessageQueueSize int IncomingMessageQueueSize int + ContinuityConfig *ContinuityConfig } // New initializes a new conversation Manager. @@ -169,6 +205,30 @@ func New( return nil, err } + // Default continuity config + continuityConfig := ContinuityConfig{ + BatchCheckInterval: 2 * time.Minute, + OfflineThreshold: 3 * time.Minute, + MinEmailInterval: 15 * time.Minute, + MaxMessagesPerEmail: 10, + } + + // Override with provided config if available + if opts.ContinuityConfig != nil { + if opts.ContinuityConfig.BatchCheckInterval > 0 { + continuityConfig.BatchCheckInterval = opts.ContinuityConfig.BatchCheckInterval + } + if opts.ContinuityConfig.OfflineThreshold > 0 { + continuityConfig.OfflineThreshold = opts.ContinuityConfig.OfflineThreshold + } + if opts.ContinuityConfig.MinEmailInterval > 0 { + continuityConfig.MinEmailInterval = opts.ContinuityConfig.MinEmailInterval + } + if opts.ContinuityConfig.MaxMessagesPerEmail > 0 { + continuityConfig.MaxMessagesPerEmail = opts.ContinuityConfig.MaxMessagesPerEmail + } + } + c := &Manager{ q: q, wsHub: wsHub, @@ -191,6 +251,7 @@ func New( incomingMessageQueue: make(chan models.IncomingMessage, opts.IncomingMessageQueueSize), outgoingMessageQueue: make(chan models.Message, opts.OutgoingMessageQueueSize), outgoingProcessingMessages: sync.Map{}, + continuityConfig: continuityConfig, } return c, nil @@ -203,12 +264,15 @@ type queries struct { GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"` GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"` GetConversations string `query:"get-conversations"` + GetContactChatConversations *sqlx.Stmt `query:"get-contact-chat-conversations"` + GetChatConversation *sqlx.Stmt `query:"get-chat-conversation"` GetContactPreviousConversations *sqlx.Stmt `query:"get-contact-previous-conversations"` GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"` GetUserActiveConversationsCount *sqlx.Stmt `query:"get-user-active-conversations-count"` - UpdateConversationFirstReplyAt *sqlx.Stmt `query:"update-conversation-first-reply-at"` - UpdateConversationLastReplyAt *sqlx.Stmt `query:"update-conversation-last-reply-at"` - UpdateConversationWaitingSince *sqlx.Stmt `query:"update-conversation-waiting-since"` + UpdateConversationFirstReplyAt *sqlx.Stmt `query:"update-conversation-first-reply-at"` + UpdateConversationLastReplyAt *sqlx.Stmt `query:"update-conversation-last-reply-at"` + UpdateConversationWaitingSince *sqlx.Stmt `query:"update-conversation-waiting-since"` + UpdateConversationContactLastSeen *sqlx.Stmt `query:"update-conversation-contact-last-seen"` UpsertUserLastSeen *sqlx.Stmt `query:"upsert-user-last-seen"` MarkConversationUnread *sqlx.Stmt `query:"mark-conversation-unread"` UpdateConversationAssignedUser *sqlx.Stmt `query:"update-conversation-assigned-user"` @@ -217,6 +281,7 @@ type queries struct { UpdateConversationPriority *sqlx.Stmt `query:"update-conversation-priority"` UpdateConversationStatus *sqlx.Stmt `query:"update-conversation-status"` UpdateConversationLastMessage *sqlx.Stmt `query:"update-conversation-last-message"` + UpdateConversationMeta *sqlx.Stmt `query:"update-conversation-meta"` InsertConversationParticipant *sqlx.Stmt `query:"insert-conversation-participant"` InsertConversation *sqlx.Stmt `query:"insert-conversation"` AddConversationTags *sqlx.Stmt `query:"add-conversation-tags"` @@ -242,23 +307,30 @@ type queries struct { GetOutgoingPendingMessages *sqlx.Stmt `query:"get-outgoing-pending-messages"` GetMessageSourceIDs *sqlx.Stmt `query:"get-message-source-ids"` GetConversationUUIDFromMessageUUID *sqlx.Stmt `query:"get-conversation-uuid-from-message-uuid"` - InsertMessage *sqlx.Stmt `query:"insert-message"` - UpdateMessageStatus *sqlx.Stmt `query:"update-message-status"` MessageExistsBySourceID *sqlx.Stmt `query:"message-exists-by-source-id"` GetConversationByMessageID *sqlx.Stmt `query:"get-conversation-by-message-id"` + InsertMessage *sqlx.Stmt `query:"insert-message"` + UpdateMessageStatus *sqlx.Stmt `query:"update-message-status"` + UpdateMessageSourceID *sqlx.Stmt `query:"update-message-source-id"` + DeleteMessage *sqlx.Stmt `query:"delete-message"` + + // Conversation continuity queries. + GetOfflineLiveChatConversations *sqlx.Stmt `query:"get-offline-livechat-conversations"` + GetUnreadMessages *sqlx.Stmt `query:"get-unread-messages"` + UpdateContinuityEmailTracking *sqlx.Stmt `query:"update-continuity-email-tracking"` // Mention queries. InsertMention *sqlx.Stmt `query:"insert-mention"` } // CreateConversation creates a new conversation and returns its ID and UUID. -func (c *Manager) CreateConversation(contactID, contactChannelID, inboxID int, lastMessage string, lastMessageAt time.Time, subject string, appendRefNumToSubject bool) (int, string, error) { +func (c *Manager) CreateConversation(contactID, inboxID int, lastMessage string, lastMessageAt time.Time, subject string, appendRefNumToSubject bool) (int, string, error) { var ( id int uuid string prefix string ) - if err := c.q.InsertConversation.QueryRow(contactID, contactChannelID, models.StatusOpen, inboxID, lastMessage, lastMessageAt, subject, prefix, appendRefNumToSubject).Scan(&id, &uuid); err != nil { + if err := c.q.InsertConversation.QueryRow(contactID, models.StatusOpen, inboxID, lastMessage, lastMessageAt, subject, prefix, appendRefNumToSubject).Scan(&id, &uuid); err != nil { c.lo.Error("error inserting new conversation into the DB", "error", err) return id, uuid, err } @@ -279,15 +351,16 @@ func (c *Manager) GetConversation(id int, uuid, refNum string) (models.Conversat c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.conversation}"), nil) } c.lo.Error("error fetching conversation", "error", err) - return conversation, envelope.NewError(envelope.GeneralError, - c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil) + return conversation, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil) } // Strip name and extract plain email from "Name " - var err error - conversation.InboxMail, err = stringutil.ExtractEmail(conversation.InboxMail) - if err != nil { - c.lo.Error("error extracting email from inbox mail", "inbox_mail", conversation.InboxMail, "error", err) + if conversation.InboxMail != "" { + var err error + conversation.InboxMail, err = stringutil.ExtractEmail(conversation.InboxMail) + if err != nil { + c.lo.Error("error extracting email from inbox mail", "inbox_mail", conversation.InboxMail, "error", err) + } } return conversation, nil @@ -303,6 +376,29 @@ func (c *Manager) GetContactPreviousConversations(contactID int, limit int) ([]m return conversations, nil } +// GetContactChatConversations retrieves chat conversations for a contact in a specific inbox. +func (c *Manager) GetContactChatConversations(contactID, inboxID int) ([]models.ChatConversation, error) { + var conversations = make([]models.ChatConversation, 0) + if err := c.q.GetContactChatConversations.Select(&conversations, contactID, inboxID); err != nil { + c.lo.Error("error fetching conversations", "error", err) + return conversations, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil) + } + return conversations, nil +} + +// GetChatConversation retrieves a single chat conversation by UUID +func (c *Manager) GetChatConversation(conversationUUID string) (models.ChatConversation, error) { + var conversation models.ChatConversation + if err := c.q.GetChatConversation.Get(&conversation, conversationUUID); err != nil { + c.lo.Error("error fetching chat conversation", "uuid", conversationUUID, "error", err) + return conversation, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil) + } + if conversation.Assignee.ID == 0 { + conversation.Assignee = nil + } + return conversation, nil +} + // GetConversationsCreatedAfter retrieves conversations created after the specified time. func (c *Manager) GetConversationsCreatedAfter(time time.Time) ([]models.Conversation, error) { var conversations = make([]models.Conversation, 0) @@ -331,6 +427,18 @@ func (c *Manager) MarkAsUnread(uuid string, userID int) error { return nil } +// UpdateContactLastSeen updates the last seen timestamp of the contact in the conversation. +func (c *Manager) UpdateConversationContactLastSeen(uuid string) error { + if _, err := c.q.UpdateConversationContactLastSeen.Exec(uuid); err != nil { + c.lo.Error("error updating contact last seen timestamp", "conversation_id", uuid, "error", err) + return envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.conversation}"), nil) + } + + // Broadcast the property update to all subscribers. + c.BroadcastConversationUpdate(uuid, "contact_last_seen_at", time.Now().Format(time.RFC3339)) + return nil +} + // GetConversationParticipants retrieves the participants of a conversation. func (c *Manager) GetConversationParticipants(uuid string) ([]models.ConversationParticipant, error) { conv := make([]models.ConversationParticipant, 0) @@ -478,14 +586,23 @@ func (c *Manager) ActiveUserConversationsCount(userID int) (int, error) { // UpdateConversationLastMessage updates the last message details for a conversation. // Also conditionally updates last_interaction fields if messageType != 'activity' and !private. -func (c *Manager) UpdateConversationLastMessage(conversation int, conversationUUID, lastMessage, lastMessageSenderType, messageType string, private bool, lastMessageAt time.Time) error { - if _, err := c.q.UpdateConversationLastMessage.Exec(conversation, conversationUUID, lastMessage, lastMessageSenderType, lastMessageAt, messageType, private); err != nil { +func (c *Manager) UpdateConversationLastMessage(conversation int, conversationUUID, lastMessage, lastMessageSenderType, messageType string, private bool, lastMessageAt time.Time, senderID int) error { + if _, err := c.q.UpdateConversationLastMessage.Exec(conversation, conversationUUID, lastMessage, lastMessageSenderType, lastMessageAt, messageType, private, senderID); err != nil { c.lo.Error("error updating conversation last message", "error", err) return err } return nil } +// UpdateConversationMeta updates meta data for a conversation. +func (c *Manager) UpdateConversationMeta(uuid string, meta map[string]any) error { + if _, err := c.q.UpdateConversationMeta.Exec(uuid, meta); err != nil { + c.lo.Error("error updating conversation meta", "error", err) + return err + } + return nil +} + // UpdateConversationFirstReplyAt updates the first reply timestamp for a conversation. func (c *Manager) UpdateConversationFirstReplyAt(conversationUUID string, conversationID int, at time.Time) error { res, err := c.q.UpdateConversationFirstReplyAt.Exec(conversationID, at) @@ -566,6 +683,9 @@ func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, ac return envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.conversation}"), nil) } + // Broadcast conversation update to widget clients + c.BroadcastConversationToWidget(uuid) + return nil } @@ -615,6 +735,10 @@ func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor // Evaluate automation rules for conversation team assignment. c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationTeamAssigned) } + + // Broadcast conversation update to widget clients + c.BroadcastConversationToWidget(uuid) + return nil } @@ -758,6 +882,10 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn } c.BroadcastConversationUpdate(uuid, "resolved_at", resolvedAt.Format(time.RFC3339)) } + + // Broadcast conversation update to widget clients + c.BroadcastConversationToWidget(uuid) + return nil } @@ -1123,6 +1251,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio []mmodels.Media{}, conv.InboxID, user.ID, + conv.ContactID, conv.UUID, action.Value[0], to, @@ -1167,6 +1296,9 @@ func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.Use "actor_id": actor.ID, "conversation": conversation, }) + + // Broadcast conversation update to widget clients when user assignee is removed + m.BroadcastConversationToWidget(uuid) } // Broadcast ws update. @@ -1203,8 +1335,8 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil) } - // Queue CSAT reply. - _, err = m.QueueReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta) + // Send CSAT reply. + _, err = m.QueueReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.ContactID, conversation.UUID, message, to, cc, bcc, meta) if err != nil { m.lo.Error("error sending CSAT reply", "conversation_uuid", conversation.UUID, "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil) @@ -1423,3 +1555,204 @@ func (c *Manager) makeConversationsListQuery(viewingUserID, userID int, teamIDs "users": usersAllowedFields, }) } + +// 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.Rating + if csat.Feedback.Valid { + feedback = csat.Feedback.String + } + } + + // Censor content and add submission status + msg.CensorCSATContentWithStatus(isSubmitted, csatUUID, rating, feedback) + } + } +} + +// BuildWidgetConversationView builds the conversation view data for widget clients +func (m *Manager) BuildWidgetConversationView(conversation models.Conversation) (WidgetConversationView, error) { + view := WidgetConversationView{ + UUID: conversation.UUID, + Status: conversation.Status.String, + Assignee: nil, + } + + // Fetch assignee details if assigned + if conversation.AssignedUserID.Int > 0 { + assignee, err := m.userStore.GetAgent(conversation.AssignedUserID.Int, "") + if err != nil { + m.lo.Error("error fetching conversation assignee for widget", "conversation_uuid", conversation.UUID, "error", err) + } else { + // Convert assignee avatar URL to signed URL if needed + if assignee.AvatarURL.Valid && assignee.AvatarURL.String != "" { + avatarPath := assignee.AvatarURL.String + if strings.HasPrefix(avatarPath, "/uploads/") { + avatarUUID := strings.TrimPrefix(avatarPath, "/uploads/") + assignee.AvatarURL = null.StringFrom(m.mediaStore.GetSignedURL(avatarUUID)) + } + } + + // Build assignee object + view.Assignee = map[string]interface{}{ + "id": assignee.ID, + "first_name": assignee.FirstName, + "last_name": assignee.LastName, + "avatar_url": assignee.AvatarURL, + "availability_status": assignee.AvailabilityStatus, + "type": assignee.Type, + "active_at": assignee.LastActiveAt, + } + } + } + + // Calculate business hours info + businessHoursID, utcOffset := m.calculateBusinessHoursInfo(conversation) + if businessHoursID != nil { + view.BusinessHoursID = businessHoursID + } + if utcOffset != nil { + view.WorkingHoursUTCOffset = utcOffset + } + + return view, nil +} + +// BuildWidgetConversationResponse builds the full conversation response for widget including messages +func (m *Manager) BuildWidgetConversationResponse(conversation models.Conversation, includeMessages bool) (WidgetConversationResponse, error) { + var resp = WidgetConversationResponse{} + + chatConversation, err := m.GetChatConversation(conversation.UUID) + if err != nil { + return resp, err + } + resp.Conversation = chatConversation + + // Build messages if requested + if includeMessages { + private := false + messages, _, err := m.GetConversationMessages(conversation.UUID, 1, 400, &private, []string{models.MessageIncoming, models.MessageOutgoing}) + if err != nil { + m.lo.Error("error fetching conversation messages", "conversation_uuid", conversation.UUID, "error", err) + return resp, envelope.NewError(envelope.GeneralError, "error fetching messages", nil) + } + + m.ProcessCSATStatus(messages) + + // Convert to chat message format + chatMessages := make([]models.ChatMessage, len(messages)) + userCache := make(map[int]umodels.User) + for i, msg := range messages { + // Generate signed URLs for attachments + attachments := msg.Attachments + for j := range attachments { + attachments[j].URL = m.mediaStore.GetSignedURL(attachments[j].UUID) + } + + // Fetch sender from cache or store + var user umodels.User + if cachedUser, ok := userCache[msg.SenderID]; ok { + user = cachedUser + } else { + user, err = m.userStore.Get(msg.SenderID, "", []string{}) + if err != nil { + m.lo.Error("error fetching message sender user", "sender_id", msg.SenderID, "conversation_uuid", conversation.UUID, "error", err) + } else { + userCache[msg.SenderID] = user + } + } + + chatMessages[i] = models.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, + } + } + resp.Messages = chatMessages + } + + // Calculate business hours info + businessHoursID, utcOffset := m.calculateBusinessHoursInfo(conversation) + resp.BusinessHoursID = businessHoursID + resp.WorkingHoursUTCOffset = utcOffset + + return resp, nil +} + +// calculateBusinessHoursInfo calculates business hours ID and UTC offset for a conversation +func (m *Manager) calculateBusinessHoursInfo(conversation models.Conversation) (*int, *int) { + var ( + businessHoursID *int + timezone string + utcOffset *int + ) + + // Check if conversation is assigned to a team with business hours + if conversation.AssignedTeamID.Valid { + team, err := m.teamStore.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 := m.settingsStore.GetByPrefix("app") + if err == nil { + var settings map[string]any + if err := json.Unmarshal([]byte(out), &settings); err == nil { + if bhIDStr, ok := settings["app.business_hours_id"].(string); ok && bhIDStr != "" { + if bhID, err := strconv.Atoi(bhIDStr); err == nil { + businessHoursID = &bhID + } + } + if tz, ok := settings["app.timezone"].(string); ok { + timezone = tz + } + } + } + } + + // 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 + utcOffset = &offsetMinutes + } + } + + return businessHoursID, utcOffset +} diff --git a/internal/conversation/message.go b/internal/conversation/message.go index df1d7968..51b261ef 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) } } @@ -139,13 +140,13 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { } // Get inbox - inbox, err := m.inboxStore.Get(message.InboxID) + inb, err := m.inboxStore.Get(message.InboxID) if handleError(err, "error fetching inbox") { return } // Render content in template - if err := m.RenderMessageInTemplate(inbox.Channel(), &message); err != nil { + if err := m.RenderMessageInTemplate(inb.Channel(), &message); err != nil { handleError(err, "error rendering content in template") return } @@ -156,33 +157,36 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { return } - // Set from address of the inbox - message.From = inbox.FromAddress() + if inb.Channel() == inbox.ChannelEmail { + // Set from address of the inbox + message.From = inb.FromAddress() - // Set "In-Reply-To" and "References" headers, logging any errors but continuing to send the message. - // Include only the last 20 messages as references to avoid exceeding header size limits. - message.References, err = m.GetMessageSourceIDs(message.ConversationID, 20) - if err != nil { - m.lo.Error("Error fetching conversation source IDs", "error", err) - } + // Set "In-Reply-To" and "References" headers, logging any errors but continuing to send the message. + // Include only the last 20 messages as references to avoid exceeding header size limits. + message.References, err = m.GetMessageSourceIDs(message.ConversationID, 20) + if err != nil { + m.lo.Error("Error fetching conversation source IDs", "error", err) + } - // References is sorted in DESC i.e newest message first, so reverse it to keep the references in order. - slices.Reverse(message.References) + // References is sorted in DESC i.e newest message first, so reverse it to keep the references in order. + slices.Reverse(message.References) - // Remove the current message ID from the references. - message.References = stringutil.RemoveItemByValue(message.References, message.SourceID.String) + // Remove the current message ID from the references. + message.References = stringutil.RemoveItemByValue(message.References, message.SourceID.String) - if len(message.References) > 0 { - message.InReplyTo = message.References[len(message.References)-1] + if len(message.References) > 0 { + message.InReplyTo = message.References[len(message.References)-1] + } } // Send message - err = inbox.Send(message) - if handleError(err, "error sending message") { + err = inb.Send(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. @@ -278,6 +282,9 @@ func (m *Manager) RenderMessageInTemplate(channel string, message *models.Messag m.lo.Error("could not render email content using template", "id", message.ID, "error", err) return fmt.Errorf("could not render email content using template: %w", err) } + case inbox.ChannelLiveChat: + // Live chat doesn't use templates for rendering messages. + return nil default: m.lo.Warn("unknown message channel", "channel", channel) return fmt.Errorf("unknown message channel: %s", channel) @@ -410,57 +417,77 @@ func (m *Manager) CreateContactMessage(media []mmodels.Media, contactID int, con } // QueueReply queues a reply message in a conversation. -func (m *Manager) QueueReply(media []mmodels.Media, inboxID, senderID int, conversationUUID, content string, to, cc, bcc []string, meta map[string]interface{}) (models.Message, error) { +func (m *Manager) QueueReply(media []mmodels.Media, inboxID, senderID, contactID int, conversationUUID, content string, to, cc, bcc []string, meta map[string]interface{}) (models.Message, error) { var ( message = models.Message{} + metaMap = map[string]interface{}{} ) - // Clear empty fields in to, cc, bcc. - to = stringutil.RemoveEmpty(to) - cc = stringutil.RemoveEmpty(cc) - bcc = stringutil.RemoveEmpty(bcc) - - if len(to) == 0 { - return message, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.empty", "name", "`to`"), nil) + inboxRecord, err := m.inboxStore.GetDBRecord(inboxID) + if err != nil { + m.lo.Error("error fetching inbox record", "inbox_id", inboxID, "error", err) + return models.Message{}, err } - meta["to"] = to - if len(cc) > 0 { - meta["cc"] = cc - } - if len(bcc) > 0 { - meta["bcc"] = bcc + if !inboxRecord.Enabled { + return models.Message{}, envelope.NewError(envelope.InputError, m.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil) } - metaJSON, err := json.Marshal(meta) - if err != nil { - return message, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorMarshalling", "name", "{globals.terms.meta}"), nil) + var sourceID string + 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 models.Message{}, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.empty", "name", "`to`"), nil) + } + sourceID, err = stringutil.GenerateEmailMessageID(conversationUUID, inboxRecord.From) + if err != nil { + m.lo.Error("error generating source message id", "error", err) + return models.Message{}, envelope.NewError(envelope.GeneralError, m.i18n.T("conversation.errorGeneratingMessageID"), nil) + } + case inbox.ChannelLiveChat: + // TODO: Is source id needed for live chat messages? + sourceID, err = stringutil.RandomAlphanumeric(16) + if err != nil { + m.lo.Error("error generating random source id", "error", err) + return models.Message{}, envelope.NewError(envelope.GeneralError, m.i18n.T("conversation.errorGeneratingMessageID"), nil) + } + sourceID = "livechat-" + conversationUUID + "-" + sourceID } - // Generate unique source ID i.e. message-id for email. - inbox, err := m.inboxStore.GetDBRecord(inboxID) - if err != nil { - return message, err - } - sourceID, err := stringutil.GenerateEmailMessageID(conversationUUID, inbox.From) + // Marshal meta. + metaJSON, err := json.Marshal(metaMap) if err != nil { - m.lo.Error("error generating source message id", "error", err) - return message, envelope.NewError(envelope.GeneralError, m.i18n.T("conversation.errorGeneratingMessageID"), nil) + m.lo.Error("error marshalling message meta map to JSON", "error", err) + return models.Message{}, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorInserting", "name", "{globals.terms.message}"), nil) } - // Insert Message. + // Insert the message into the database message = models.Message{ - ConversationUUID: conversationUUID, - SenderID: senderID, - Type: models.MessageOutgoing, - SenderType: models.SenderTypeAgent, - Status: models.MessageStatusPending, - Content: content, - ContentType: models.ContentTypeHTML, - Private: false, - Media: media, - Meta: metaJSON, - SourceID: null.StringFrom(sourceID), + ConversationUUID: conversationUUID, + SenderID: senderID, + Type: models.MessageOutgoing, + SenderType: models.SenderTypeAgent, + Status: models.MessageStatusPending, + Content: content, + ContentType: models.ContentTypeHTML, + Private: false, + Media: media, + SourceID: null.StringFrom(sourceID), + MessageReceiverID: contactID, + Meta: metaJSON, } if err := m.InsertMessage(&message); err != nil { return models.Message{}, err @@ -485,10 +512,8 @@ func (m *Manager) InsertMessage(message *models.Message) error { // Convert HTML content to text for search. message.TextContent = stringutil.HTML2Text(message.Content) - // Insert and scan the message into the struct. - if err := m.q.InsertMessage.Get(message, - message.Type, message.Status, message.ConversationID, message.ConversationUUID, - message.Content, message.TextContent, message.SenderID, message.SenderType, + // Insert Message. + 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) @@ -499,7 +524,7 @@ func (m *Manager) InsertMessage(message *models.Message) error { m.mediaStore.Attach(media.ID, mmodels.ModelMessages, message.ID) } - // Add this user as a participant. + // Add this user as a participant if not already present. m.addConversationParticipant(message.SenderID, message.ConversationUUID) // Hide CSAT message content as it contains a public link to the survey. @@ -509,7 +534,7 @@ func (m *Manager) InsertMessage(message *models.Message) error { } // Update conversation last message details (also conditionally updates last_interaction if not activity/private). - m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, lastMessage, message.SenderType, message.Type, message.Private, message.CreatedAt) + m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, lastMessage, message.SenderType, message.Type, message.Private, message.CreatedAt, message.SenderID) // Broadcast new message. m.BroadcastNewMessage(message) @@ -639,36 +664,41 @@ 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 { +func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Message, error) { + var ( + isNewConversation = false + conversationID int + err 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 + if in.Contact.ID == 0 && in.Contact.Email.Valid { + if err := m.userStore.CreateContact(&in.Contact); err != nil { + return models.Message{}, fmt.Errorf("creating contact: %w", err) + } } in.Message.SenderID = in.Contact.ID // Message exists by source ID? - conversationID, err := m.messageExistsBySourceID([]string{in.Message.SourceID.String}) + conversationID, err = m.messageExistsBySourceID([]string{in.Message.SourceID.String}) if err != nil && err != errConversationNotFound { - return err + return models.Message{}, err } if conversationID > 0 { - return nil + return models.Message{}, nil } - var isNewConversation bool - // Try to match by plus-addressed Reply-To (e.g., inbox+conv-{uuid}@domain) if in.ConversationUUIDFromReplyTo != "" { conversation, err := m.GetConversation(0, in.ConversationUUIDFromReplyTo, "") if err != nil { envErr, ok := err.(envelope.Error) if !ok || envErr.ErrorType != envelope.NotFoundError { - return fmt.Errorf("fetching conversation: %w", err) + return models.Message{}, fmt.Errorf("fetching conversation: %w", err) } } @@ -695,7 +725,7 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { if err != nil { envErr, ok := err.(envelope.Error) if !ok || envErr.ErrorType != envelope.NotFoundError { - return fmt.Errorf("fetching conversation: %w", err) + return models.Message{}, fmt.Errorf("fetching conversation: %w", err) } } if conversation.Contact.Email.String != "" && strings.EqualFold(conversation.Contact.Email.String, in.Contact.Email.String) { @@ -711,9 +741,9 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { // If conversation not matched via reference number, find conversation using references and in-reply-to headers else create a new one. if in.Message.ConversationID == 0 { - isNewConversation, err = m.findOrCreateConversation(&in.Message, in.InboxID, in.Contact.ContactChannelID, in.Contact.ID) + isNewConversation, err = m.findOrCreateConversation(&in.Message, in.InboxID, in.Contact.ID) if err != nil { - return err + return models.Message{}, err } } @@ -723,64 +753,23 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { if isNewConversation && in.Message.ConversationUUID != "" { m.lo.Info("deleting conversation as message attachment upload failed", "conversation_uuid", in.Message.ConversationUUID, "message_source_id", in.Message.SourceID) if err := m.DeleteConversation(in.Message.ConversationUUID); err != nil { - return fmt.Errorf("error deleting conversation after message attachment upload failure: %w", err) + return models.Message{}, fmt.Errorf("error deleting conversation after message attachment upload failure: %w", err) } } - return fmt.Errorf("error uploading message attachments: %w", upErr) + return models.Message{}, fmt.Errorf("error uploading message attachments: %w", upErr) } // Insert message. if err = m.InsertMessage(&in.Message); err != nil { - return 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 - } - - // 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) - } + return models.Message{}, 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) - } + // 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) } - return nil + return in.Message, nil } // MessageExists checks if a message with the given messageID exists. @@ -912,7 +901,7 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) error { } // findOrCreateConversation finds or creates a conversation for the given message. -func (m *Manager) findOrCreateConversation(in *models.Message, inboxID, contactChannelID, contactID int) (bool, error) { +func (m *Manager) findOrCreateConversation(in *models.Message, inboxID, contactID int) (bool, error) { var ( new bool err error @@ -934,7 +923,7 @@ func (m *Manager) findOrCreateConversation(in *models.Message, inboxID, contactC new = true lastMessage := stringutil.HTML2Text(in.Content) lastMessageAt := time.Now() - conversationID, conversationUUID, err = m.CreateConversation(contactID, contactChannelID, inboxID, lastMessage, lastMessageAt, in.Subject, false /**append reference number to subject**/) + conversationID, conversationUUID, err = m.CreateConversation(contactID, inboxID, lastMessage, lastMessageAt, in.Subject, false /**append reference number to subject**/) if err != nil || conversationID == 0 { return new, err } @@ -970,32 +959,46 @@ func (m *Manager) messageExistsBySourceID(messageSourceIDs []string) (int, error return conversationID, nil } -// attachAttachmentsToMessage attaches attachment blobs to message. -func (m *Manager) attachAttachmentsToMessage(message *models.Message) error { +// fetchMessageAttachments fetches attachments for a single message ID - extracted for reuse +func (m *Manager) fetchMessageAttachments(messageID int) (attachment.Attachments, error) { var attachments attachment.Attachments - // Get all media for this message. - medias, err := m.mediaStore.GetByModel(message.ID, mmodels.ModelMessages) + // Get all media for this message + medias, err := m.mediaStore.GetByModel(messageID, mmodels.ModelMessages) if err != nil { - m.lo.Error("error fetching message attachments", "error", err) - return err + return attachments, fmt.Errorf("error fetching message attachments: %w", err) } - // Fetch blobs. + // Fetch blobs for each media item for _, media := range medias { blob, err := m.mediaStore.GetBlob(media.UUID) if err != nil { - m.lo.Error("error fetching media blob", "error", err) - return err + return attachments, fmt.Errorf("error fetching media blob: %w", 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.GetSignedURL(media.UUID), } attachments = append(attachments, attachment) } + return attachments, nil +} + +// attachAttachmentsToMessage attaches attachment blobs to message. +func (m *Manager) attachAttachmentsToMessage(message *models.Message) error { + attachments, err := m.fetchMessageAttachments(message.ID) + if err != nil { + m.lo.Error("error fetching message attachments", "error", err) + return err + } + // Attach attachments. message.Attachments = attachments @@ -1051,3 +1054,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 1f97d5fa..2081ee5c 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" @@ -57,6 +58,53 @@ var ( ContentTypeHTML = "html" ) +type ContinuityConversation struct { + ID int `db:"id"` + UUID string `db:"uuid"` + ContactID int `db:"contact_id"` + InboxID int `db:"inbox_id"` + ContactLastSeenAt time.Time `db:"contact_last_seen_at"` + LastContinuityEmailSentAt null.Time `db:"last_continuity_email_sent_at"` + ContactEmail null.String `db:"contact_email"` + ContactFirstName null.String `db:"contact_first_name"` + ContactLastName null.String `db:"contact_last_name"` + LinkedEmailInboxID null.Int `db:"linked_email_inbox_id"` +} + +type ContinuityUnreadMessage struct { + Message + SenderFirstName null.String `db:"sender.first_name"` + SenderLastName null.String `db:"sender.last_name"` + SenderType string `db:"sender.type"` +} + +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 { + 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 { + 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"` +} + // ConversationListItem represents a conversation in list views type ConversationListItem struct { Total int `db:"total" json:"-"` @@ -125,7 +173,7 @@ type Conversation struct { InboxName string `db:"inbox_name" json:"inbox_name"` InboxChannel string `db:"inbox_channel" json:"inbox_channel"` Tags null.JSON `db:"tags" json:"tags"` - Meta pq.StringArray `db:"meta" json:"meta"` + Meta json.RawMessage `db:"meta" json:"meta"` CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"` LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"` LastMessage null.String `db:"last_message" json:"last_message"` @@ -214,38 +262,39 @@ type NewConversationsStats struct { // Message represents a message in a conversation type Message struct { - Total int `db:"total" json:"-"` - ID int `db:"id" json:"id"` - 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"` - ConversationUUID string `db:"conversation_uuid" json:"conversation_uuid"` - 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"` - Author MessageAuthor `db:"author" json:"author"` - InboxID int `db:"inbox_id" json:"-"` - Meta json.RawMessage `db:"meta" json:"meta"` - Attachments attachment.Attachments `db:"attachments" json:"attachments"` - 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 `json:"-"` - Media []mmodels.Media `json:"-"` - IsCSAT bool `json:"-"` + Total int `db:"total" json:"-"` + ID int `db:"id" json:"id"` + 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"` + ConversationUUID string `db:"conversation_uuid" json:"conversation_uuid"` + 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"` + 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 `json:"-"` + Media []mmodels.Media `json:"-"` + IsCSAT bool `json:"-"` + Author MessageAuthor `db:"author" json:"author"` } // CensorCSATContent redacts the content of a CSAT message to prevent leaking the CSAT survey public link. @@ -255,8 +304,37 @@ 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) + } } } @@ -270,8 +348,40 @@ func (m *Message) HasCSAT() bool { return isCsat } +// 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 "" +} + // IncomingMessage links a message with the contact information and inbox id. type IncomingMessage struct { + Channel string ConversationUUIDFromReplyTo string // UUID extracted from plus-addressed recipient (e.g., inbox+conv-{uuid}@domain) Message Message Contact umodels.User diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index 35c55a0e..da47c1a6 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -6,23 +6,22 @@ WHERE snoozed_until <= NOW(); -- name: insert-conversation WITH status_id AS ( - SELECT id FROM conversation_statuses WHERE name = $3 + SELECT id FROM conversation_statuses WHERE name = $2 ), reference_number AS ( - SELECT generate_reference_number($8) AS reference_number + SELECT generate_reference_number($7) AS reference_number ) INSERT INTO conversations -(contact_id, contact_channel_id, status_id, inbox_id, last_message, last_message_at, subject, reference_number) +(contact_id, status_id, inbox_id, last_message, last_message_at, subject, reference_number) VALUES( $1, - $2, (SELECT id FROM status_id), + $3, $4, $5, - $6, CASE - WHEN $9 = TRUE THEN CONCAT($7::text, ' - #', (SELECT reference_number FROM reference_number), '') - ELSE $7::text + WHEN $8 = TRUE THEN CONCAT($6::text, ' - #', (SELECT reference_number FROM reference_number), '') + ELSE $6::text END, (SELECT reference_number FROM reference_number) ) @@ -167,6 +166,7 @@ SELECT ct.enabled as "contact.enabled", ct.last_active_at as "contact.last_active_at", ct.last_login_at as "contact.last_login_at", + ct.external_user_id as "contact.external_user_id", as_latest.first_response_deadline_at, as_latest.resolution_deadline_at, as_latest.id as applied_sla_id, @@ -218,14 +218,88 @@ SELECT u.first_name AS "contact.first_name", u.last_name AS "contact.last_name", u.avatar_url AS "contact.avatar_url", - c.last_message, - c.last_message_at + c.last_message as last_message, + c.last_message_at as last_message_at FROM users u JOIN conversations c ON c.contact_id = u.id WHERE c.contact_id = $1 ORDER BY c.created_at DESC LIMIT $2; +-- name: get-chat-conversation +SELECT + c.created_at, + c.uuid, + cs.name as status, + COALESCE(c.last_interaction, '') as "last_message.content", + c.last_interaction_at as "last_message.created_at", + COALESCE(lis.id::TEXT, '') AS "last_message.author.id", + COALESCE(lis.first_name, '') AS "last_message.author.first_name", + COALESCE(lis.last_name, '') AS "last_message.author.last_name", + COALESCE(lis.avatar_url, '') AS "last_message.author.avatar_url", + COALESCE(c.last_interaction_sender::TEXT, '') AS "last_message.author.type", + (SELECT CASE WHEN COUNT(*) > 9 THEN 10 ELSE COUNT(*) END + FROM ( + SELECT 1 FROM conversation_messages unread + WHERE unread.conversation_id = c.id + AND unread.created_at > c.contact_last_seen_at + AND unread.type IN ('incoming', 'outgoing') + AND unread.private = false + LIMIT 10 + ) t) 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 conversation_statuses cs ON c.status_id = cs.id +LEFT JOIN users au ON c.assigned_user_id = au.id +LEFT JOIN users lis ON c.last_interaction_sender_id = lis.id +WHERE c.uuid = $1 + AND inb.deleted_at IS NULL; + +-- name: get-contact-chat-conversations +SELECT + c.created_at, + c.uuid, + cs.name as status, + COALESCE(c.last_interaction, '') as "last_message.content", + c.last_interaction_at as "last_message.created_at", + COALESCE(lis.id::TEXT, '') AS "last_message.author.id", + COALESCE(lis.first_name, '') AS "last_message.author.first_name", + COALESCE(lis.last_name, '') AS "last_message.author.last_name", + COALESCE(lis.avatar_url, '') AS "last_message.author.avatar_url", + COALESCE(c.last_interaction_sender::TEXT, '') AS "last_message.author.type", + (SELECT CASE WHEN COUNT(*) > 9 THEN 10 ELSE COUNT(*) END + FROM ( + SELECT 1 FROM conversation_messages unread + WHERE unread.conversation_id = c.id + AND unread.created_at > c.contact_last_seen_at + AND unread.type IN ('incoming', 'outgoing') + AND unread.private = false + LIMIT 10 + ) t) 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 +INNER JOIN users con ON c.contact_id = con.id +LEFT JOIN conversation_statuses cs ON c.status_id = cs.id +LEFT JOIN users au ON c.assigned_user_id = au.id +LEFT JOIN users lis ON c.last_interaction_sender_id = lis.id +WHERE c.contact_id = $1 AND c.inbox_id = $2 + AND inb.deleted_at IS NULL + AND con.deleted_at IS NULL +ORDER BY c.created_at DESC +LIMIT 500; + -- name: get-conversation-uuid SELECT uuid from conversations where id = $1; @@ -235,12 +309,24 @@ SET assigned_user_id = $2, updated_at = NOW() WHERE uuid = $1; +-- name: update-conversation-contact-last-seen +UPDATE conversations +SET contact_last_seen_at = NOW(), +updated_at = NOW() +WHERE uuid = $1; + -- name: update-conversation-assigned-team UPDATE conversations SET assigned_team_id = $2, updated_at = NOW() WHERE uuid = $1; +-- name: update-conversation-meta +UPDATE conversations +SET meta = COALESCE(meta, '{}'::jsonb) || $2, + updated_at = NOW() +WHERE uuid = $1; + -- name: update-conversation-status UPDATE conversations SET status_id = (SELECT id FROM conversation_statuses WHERE name = $2), @@ -266,13 +352,15 @@ ON CONFLICT (conversation_id, user_id) DO UPDATE SET last_seen_at = NOW(), updated_at = NOW(); -- name: update-conversation-last-message --- $1=id, $2=uuid, $3=content, $4=sender_type, $5=timestamp, $6=message_type, $7=private +-- $1=id, $2=uuid, $3=content, $4=sender_type, $5=timestamp, $6=message_type, $7=private, $8=sender_id UPDATE conversations SET last_message = $3, last_message_sender = $4, + last_message_sender_id = $8, last_message_at = $5, last_interaction = CASE WHEN $6 != 'activity' AND $7 = false THEN $3 ELSE last_interaction END, last_interaction_sender = CASE WHEN $6 != 'activity' AND $7 = false THEN $4 ELSE last_interaction_sender END, + last_interaction_sender_id = CASE WHEN $6 != 'activity' AND $7 = false THEN $8 ELSE last_interaction_sender_id END, last_interaction_at = CASE WHEN $6 != 'activity' AND $7 = false THEN $5 ELSE last_interaction_at END, updated_at = NOW() WHERE CASE @@ -424,6 +512,12 @@ WHERE m.id = $1; DELETE FROM conversations WHERE uuid = $1; -- MESSAGE queries. +-- name: delete-message +DELETE FROM conversation_messages WHERE CASE + WHEN $1 > 0 THEN id = $1 + ELSE uuid = $2 +END; + -- name: get-message-source-ids SELECT source_id @@ -443,6 +537,7 @@ SELECT m.type, m.content, m.text_content, + m.sender_type, m.content_type, m.conversation_id, m.uuid, @@ -453,10 +548,14 @@ SELECT c.uuid as conversation_uuid, 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.contact_id as message_receiver_id, c.subject FROM conversation_messages m INNER JOIN conversations c ON c.id = m.conversation_id @@ -602,6 +701,72 @@ AND m.private = NOT $4 ORDER BY m.created_at DESC LIMIT 1; +-- name: update-message-source-id +UPDATE conversation_messages SET source_id = $1 WHERE id = $2; + +-- name: get-offline-livechat-conversations +SELECT + c.id, + c.uuid, + c.contact_id, + c.inbox_id, + c.contact_last_seen_at, + c.last_continuity_email_sent_at, + i.linked_email_inbox_id, + u.email as contact_email, + u.first_name as contact_first_name, + u.last_name as contact_last_name +FROM conversations c +JOIN users u ON u.id = c.contact_id +JOIN inboxes i ON i.id = c.inbox_id +WHERE i.channel = 'livechat' + AND i.enabled = TRUE + AND i.linked_email_inbox_id IS NOT NULL + AND c.contact_last_seen_at IS NOT NULL + AND c.contact_last_seen_at > NOW() - INTERVAL '1 hour' + AND c.contact_last_seen_at < NOW() - MAKE_INTERVAL(mins => $1) + AND EXISTS ( + SELECT 1 FROM conversation_messages cm + WHERE cm.conversation_id = c.id + AND cm.created_at > c.contact_last_seen_at + AND cm.type = 'outgoing' + AND cm.private = false + ) + AND u.email > '' + AND (c.last_continuity_email_sent_at IS NULL + OR c.last_continuity_email_sent_at < NOW() - MAKE_INTERVAL(mins => $2)); + +-- name: get-unread-messages +SELECT + m.id, + m.created_at, + m.updated_at, + m.status, + m.type, + m.content, + m.text_content, + m.uuid, + m.private, + m.sender_id, + m.sender_type, + m.meta, + u.first_name as "sender.first_name", + u.last_name as "sender.last_name", + u.type as "sender.type" +FROM conversation_messages m +LEFT JOIN users u ON u.id = m.sender_id +WHERE m.conversation_id = $1 + AND m.created_at > $2 + AND m.type = 'outgoing' + AND m.private = false +ORDER BY m.created_at ASC +LIMIT $3; + +-- name: update-continuity-email-tracking +UPDATE conversations +SET contact_last_seen_at = NOW(), + last_continuity_email_sent_at = NOW() +WHERE id = $1; -- name: upsert-conversation-draft INSERT INTO conversation_drafts (conversation_id, user_id, content, meta, updated_at) VALUES ($1, $2, $3, $4, NOW()) @@ -645,4 +810,4 @@ DO UPDATE SET last_seen_at = (SELECT created_at - INTERVAL '1 second' FROM conversation_messages WHERE conversation_id = (SELECT id FROM conversations WHERE uuid = $2) ORDER BY created_at DESC LIMIT 1), - updated_at = NOW(); \ No newline at end of file + updated_at = NOW(); diff --git a/internal/conversation/ws.go b/internal/conversation/ws.go index 7a6aaa91..56cd5815 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,54 @@ 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, conversation.ContactID, isTyping) + } +} + +// BroadcastConversationToWidget broadcasts full conversation data to widget clients when conversation properties change. +func (m *Manager) BroadcastConversationToWidget(conversationUUID string) { + // Get the conversation with assignee details + conversation, err := m.GetConversation(0, conversationUUID, "") + if err != nil { + m.lo.Error("error getting conversation for widget broadcast", "error", err, "conversation_uuid", conversationUUID) + return + } + + // Build conversation view using the centralized method + conversationView, err := m.BuildWidgetConversationView(conversation) + if err != nil { + m.lo.Error("error building conversation data for widget", "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 conversation broadcast", "error", err, "inbox_id", conversation.InboxID) + return + } + + // Check if it's a livechat inbox and broadcast conversation update + if liveChatInbox, ok := inboxInstance.(*livechat.LiveChat); ok { + liveChatInbox.BroadcastConversationToClients(conversationUUID, conversation.ContactID, conversationView) + } +} 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 cfbfc4e6..cfc530c2 100644 --- a/internal/inbox/channel/email/imap.go +++ b/internal/inbox/channel/email/imap.go @@ -360,13 +360,10 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, // Make contact. firstName, lastName := getContactName(env.From[0]) var contact = umodels.User{ - InboxID: inboxID, - FirstName: firstName, - LastName: lastName, - SourceChannel: null.NewString(e.Channel(), true), - SourceChannelID: null.NewString(fromAddress, true), - Email: null.NewString(fromAddress, true), - Type: umodels.UserTypeContact, + FirstName: firstName, + LastName: lastName, + Email: null.StringFrom(fromAddress), + Type: umodels.UserTypeContact, } // Lowercase and set the `to`, `cc`, `from` and `bcc` addresses in message meta. @@ -407,6 +404,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, @@ -550,6 +548,16 @@ func (e *Email) processFullMessage(item imapclient.FetchItemDataBodySection, inc e.lo.Debug("enqueuing incoming email message", "message_id", incomingMsg.Message.SourceID.String, "attachments", len(envelope.Attachments), "inline_attachments", len(envelope.Inlines)) + // Extract conversation UUID from the email using multiple fallback methods. + // 1. Try Reply-To/To address extraction (primary method) + // 2. Try In-Reply-To header + // 3. Try References header chain + // If none of these yield a UUID, the message will be treated as a new conversation. + conversationUUID := e.extractConversationUUID(envelope) + if conversationUUID != "" { + incomingMsg.Message.ConversationUUID = conversationUUID + } + if err := e.messageStore.EnqueueIncoming(incomingMsg); err != nil { return err } @@ -607,6 +615,78 @@ func extractAllHTMLParts(part *enmime.Part) []string { return htmlParts } +// extractUUIDFromReplyAddress extracts a UUID from the reply address if present. +// The UUID is expected to be in the format "username+@domain" within the email address. +// Returns an empty string if the UUID is not found or invalid. +func (e *Email) extractUUIDFromReplyAddress(address string) string { + // Remove angle brackets if present + address = strings.Trim(address, "<>") + + // Check if it contains + + if !strings.Contains(address, "+") { + return "" + } + + // Extract the part between + and @ + parts := strings.Split(address, "@") + if len(parts) != 2 { + return "" + } + + // Get the UUID + uuid := strings.SplitN(parts[0], "+", 2)[1] + if uuid == "" { + return "" + } + + // Validate UUID format (36 chars with hyphens at specific positions) + if len(uuid) == 36 && + uuid[8] == '-' && + uuid[13] == '-' && + uuid[18] == '-' && + uuid[23] == '-' { + return uuid + } + + return "" +} + +// extractConversationUUID attempts to extract conversation UUID using multiple fallback methods. +func (e *Email) extractConversationUUID(envelope *enmime.Envelope) string { + // 1. Try Reply-To/To address extraction (primary method) + toAddresses := envelope.GetHeaderValues("To") + inboxEmail, err := stringutil.ExtractEmail(e.FromAddress()) + if err == nil { + emailUsername := strings.Split(inboxEmail, "@")[0] + for _, addr := range toAddresses { + if strings.HasPrefix(addr, emailUsername+"+") { + if uuid := e.extractUUIDFromReplyAddress(addr); uuid != "" { + e.lo.Debug("found UUID in reply-to address", "uuid", uuid) + return uuid + } + } + } + } + + // 2. Try In-Reply-To header + inReplyTo := strings.Trim(envelope.GetHeader("In-Reply-To"), "<>") + if uuid := stringutil.ExtractUUID(inReplyTo); uuid != "" { + e.lo.Debug("found UUID in In-Reply-To header", "uuid", uuid) + return uuid + } + + // 3. Try References header chain + references := strings.Fields(envelope.GetHeader("References")) + for _, ref := range references { + ref = strings.Trim(ref, "<>") + if uuid := stringutil.ExtractUUID(ref); uuid != "" { + e.lo.Debug("found UUID in References header", "uuid", uuid) + return uuid + } + } + return "" +} + // extractMessageIDFromHeaders extracts and cleans the Message-ID from email headers. // This function handles problematic Message IDs by extracting them from raw headers // and cleaning them of angle brackets and whitespace. diff --git a/internal/inbox/channel/email/imap_test.go b/internal/inbox/channel/email/imap_test.go index ce49be9d..77a2b984 100644 --- a/internal/inbox/channel/email/imap_test.go +++ b/internal/inbox/channel/email/imap_test.go @@ -6,8 +6,157 @@ import ( "github.com/emersion/go-message/mail" "github.com/jhillyerd/enmime" + "github.com/zerodha/logf" ) +func TestEmail_extractUUIDFromReplyAddress(t *testing.T) { + e := &Email{} + + testCases := []struct { + name string + address string + expected string + }{ + { + name: "Valid reply address with UUID", + address: "support+550e8400-e29b-41d4-a716-446655440000@example.com", + expected: "550e8400-e29b-41d4-a716-446655440000", + }, + { + name: "Reply address with angle brackets", + address: "", + expected: "123e4567-e89b-42d3-a456-426614174000", + }, + { + name: "No plus sign in address", + address: "support@example.com", + expected: "", + }, + { + name: "Plus sign but no UUID", + address: "support+test@example.com", + expected: "", + }, + { + name: "Invalid UUID format", + address: "support+550e8400-e29b-41d4-a716-44665544000X@example.com", + expected: "550e8400-e29b-41d4-a716-44665544000X", // extractUUIDFromReplyAddress uses simple format check + }, + { + name: "Empty address", + address: "", + expected: "", + }, + { + name: "UUID too short", + address: "support+550e8400-e29b-41d4-a716-4466554400@example.com", + expected: "", + }, + { + name: "UUID too long", + address: "support+550e8400-e29b-41d4-a716-4466554400000@example.com", + expected: "", + }, + { + name: "Multiple plus signs", + address: "support+test+550e8400-e29b-41d4-a716-446655440000@example.com", + expected: "", // "test+550e8400-e29b-41d4-a716-446655440000" is not 36 chars, so validation fails + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := e.extractUUIDFromReplyAddress(tc.address) + if result != tc.expected { + t.Errorf("extractUUIDFromReplyAddress(%q) = %q; expected %q", tc.address, result, tc.expected) + } + }) + } +} + +func TestEmail_extractConversationUUID(t *testing.T) { + logger := logf.New(logf.Opts{Level: logf.DebugLevel}) + e := &Email{ + from: "support@example.com", + lo: &logger, + } + + testCases := []struct { + name string + envelope *mockEnvelope + expected string + }{ + { + name: "UUID found in Reply-To address", + envelope: &mockEnvelope{ + headers: map[string]string{ + "To": "support+550e8400-e29b-41d4-a716-446655440000@example.com", + }, + }, + expected: "550e8400-e29b-41d4-a716-446655440000", + }, + { + name: "UUID found in In-Reply-To header", + envelope: &mockEnvelope{ + headers: map[string]string{ + "To": "support@example.com", + "In-Reply-To": "<123e4567-e89b-42d3-a456-426614174000.1735555200000000000@example.com>", + }, + }, + expected: "123e4567-e89b-42d3-a456-426614174000", + }, + { + name: "UUID found in References header", + envelope: &mockEnvelope{ + headers: map[string]string{ + "To": "support@example.com", + "In-Reply-To": "", + "References": "<123e4567-e89b-42d3-a456-426614174000.1735555200000000000@example.com> ", + }, + }, + expected: "123e4567-e89b-42d3-a456-426614174000", + }, + { + name: "No UUID found anywhere", + envelope: &mockEnvelope{ + headers: map[string]string{ + "To": "support@example.com", + "In-Reply-To": "", + "References": "", + }, + }, + expected: "", + }, + { + name: "Multiple UUIDs, returns first from Reply-To", + envelope: &mockEnvelope{ + headers: map[string]string{ + "To": "support+550e8400-e29b-41d4-a716-446655440000@example.com", + "In-Reply-To": "<123e4567-e89b-42d3-a456-426614174000.1735555200000000000@example.com>", + }, + }, + expected: "550e8400-e29b-41d4-a716-446655440000", // Reply-To takes precedence + }, + { + name: "Empty headers", + envelope: &mockEnvelope{ + headers: map[string]string{}, + }, + expected: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create a real enmime.Envelope with our mock data + envelope := createMockEnvelope(tc.envelope.headers) + result := e.extractConversationUUID(envelope) + if result != tc.expected { + t.Errorf("extractConversationUUID() = %q; expected %q", result, tc.expected) + } + }) + } +} // TestGoIMAPMessageIDParsing shows how go-imap fails to parse malformed Message-IDs // and demonstrates the fallback solution. @@ -69,7 +218,6 @@ func TestGoIMAPMessageIDParsing(t *testing.T) { } } - // TestEdgeCasesMessageID tests additional edge cases for Message-ID extraction. func TestEdgeCasesMessageID(t *testing.T) { tests := []struct { @@ -121,3 +269,22 @@ Body`, }) } } + +// mockEnvelope stores test header data +type mockEnvelope struct { + headers map[string]string +} + +// createMockEnvelope creates a minimal enmime.Envelope for testing +func createMockEnvelope(headers map[string]string) *enmime.Envelope { + // Create a minimal email content with the required headers + var emailContent strings.Builder + for key, value := range headers { + emailContent.WriteString(key + ": " + value + "\r\n") + } + emailContent.WriteString("\r\n") // Empty line to separate headers from body + emailContent.WriteString("Test body content") + + envelope, _ := enmime.ReadEnvelope(strings.NewReader(emailContent.String())) + return envelope +} diff --git a/internal/inbox/channel/livechat/livechat.go b/internal/inbox/channel/livechat/livechat.go new file mode 100644 index 00000000..b3c1ccd7 --- /dev/null +++ b/internal/inbox/channel/livechat/livechat.go @@ -0,0 +1,332 @@ +// Package livechat implements a live chat inbox for handling real-time conversations. +package livechat + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "sync" + + "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" +) + +var ( + ErrClientNotConnected = fmt.Errorf("client not connected") +) + +const ( + ChannelLiveChat = "livechat" + MaxConnectionsPerUser = 10 +) + +type PreChatFormField struct { + Key string `json:"key"` + Type string `json:"type"` + Label string `json:"label"` + Placeholder string `json:"placeholder"` + Required bool `json:"required"` + Enabled bool `json:"enabled"` + Order int `json:"order"` + IsDefault bool `json:"is_default"` + CustomAttributeID int `json:"custom_attribute_id"` +} + +// Config holds the live chat inbox configuration. +type Config struct { + BrandName string `json:"brand_name"` + DarkMode bool `json:"dark_mode"` + ShowPoweredBy bool `json:"show_powered_by"` + Language string `json:"language"` + Users struct { + AllowStartConversation bool `json:"allow_start_conversation"` + PreventMultipleConversations bool `json:"prevent_multiple_conversations"` + StartConversationButtonText string `json:"start_conversation_button_text"` + } `json:"users"` + Colors struct { + Primary string `json:"primary"` + } `json:"colors"` + Features struct { + Emoji bool `json:"emoji"` + FileUpload bool `json:"file_upload"` + } `json:"features"` + Launcher struct { + Spacing struct { + Side int `json:"side"` + Bottom int `json:"bottom"` + } `json:"spacing"` + LogoURL string `json:"logo_url"` + Position string `json:"position"` + } `json:"launcher"` + LogoURL string `json:"logo_url"` + Visitors struct { + AllowStartConversation bool `json:"allow_start_conversation"` + PreventMultipleConversations bool `json:"prevent_multiple_conversations"` + StartConversationButtonText string `json:"start_conversation_button_text"` + } `json:"visitors"` + NoticeBanner struct { + Text string `json:"text"` + Enabled bool `json:"enabled"` + } `json:"notice_banner"` + ExternalLinks []struct { + URL string `json:"url"` + Text string `json:"text"` + } `json:"external_links"` + TrustedDomains []string `json:"trusted_domains"` + DirectToConversation bool `json:"direct_to_conversation"` + GreetingMessage string `json:"greeting_message"` + ChatIntroduction string `json:"chat_introduction"` + 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"` + PreChatForm struct { + Enabled bool `json:"enabled"` + Title string `json:"title"` + Fields []PreChatFormField `json:"fields"` + } `json:"prechat_form"` +} + +// Client represents a connected chat client +type Client struct { + ID string + Channel chan []byte +} + +// LiveChat represents the live chat inbox. +type LiveChat struct { + id int + config Config + from string + lo *logf.Logger + messageStore inbox.MessageStore + userStore inbox.UserStore + clients map[string][]*Client // Maps user IDs to slices of clients (to handle multiple devices) + clientsMutex sync.RWMutex +} + +// Opts holds the options required for the live chat inbox. +type Opts struct { + ID int + Config Config + From string + Lo *logf.Logger +} + +// New returns a new instance of the live chat inbox. +func New(store inbox.MessageStore, userStore inbox.UserStore, opts Opts) (*LiveChat, error) { + lc := &LiveChat{ + id: opts.ID, + config: opts.Config, + from: opts.From, + lo: opts.Lo, + messageStore: store, + userStore: userStore, + clients: make(map[string][]*Client), + } + return lc, nil +} + +// Identifier returns the unique identifier of the inbox which is the database ID. +func (lc *LiveChat) Identifier() int { + return lc.id +} + +// Receive is no-op as messages received via api. +func (lc *LiveChat) Receive(ctx context.Context) error { + return nil +} + +// Send sends the passed message to the message receiver if they are connected to the live chat. +func (lc *LiveChat) Send(message models.Message) error { + if message.MessageReceiverID > 0 { + msgReceiverStr := strconv.Itoa(message.MessageReceiverID) + lc.clientsMutex.RLock() + clients, exists := lc.clients[msgReceiverStr] + lc.clientsMutex.RUnlock() + + if exists { + sender, err := lc.userStore.GetAgent(message.SenderID, "") + if err != nil { + lc.lo.Error("failed to get sender name", "sender_id", message.SenderID, "error", err) + return fmt.Errorf("failed to get sender name: %w", err) + } + + 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": 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, + }, + } + + // 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 + } + 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) + } + } + } else { + lc.lo.Debug("websocket client not connected for live chat message", "receiver_id", msgReceiverStr, "message_id", message.UUID) + return ErrClientNotConnected + } + } + lc.lo.Warn("received empty receiver_id for live chat message", "message_id", message.UUID, "receiver_id", message.MessageReceiverID) + return nil +} + +// Close closes the live chat channel. +func (lc *LiveChat) Close() error { + return nil +} + +// FromAddress returns the from address for this inbox. +func (lc *LiveChat) FromAddress() string { + return lc.from +} + +// Channel returns the channel name for this inbox. +func (lc *LiveChat) Channel() string { + return ChannelLiveChat +} + +// AddClient adds a new client to the live chat session. +func (lc *LiveChat) AddClient(userID string) (*Client, error) { + lc.clientsMutex.Lock() + defer lc.clientsMutex.Unlock() + + // Check if the user already has the maximum allowed connections. + if clients, exists := lc.clients[userID]; exists && len(clients) >= MaxConnectionsPerUser { + lc.lo.Warn("maximum connections reached for user", "client_id", userID, "max_connections", MaxConnectionsPerUser) + return nil, fmt.Errorf("maximum connections reached") + } + + client := &Client{ + ID: userID, + Channel: make(chan []byte, 1000), + } + + // Add the client to the clients map. + lc.clients[userID] = append(lc.clients[userID], client) + return client, nil +} + +// RemoveClient removes a client from the live chat session. +func (lc *LiveChat) RemoveClient(c *Client) { + lc.clientsMutex.Lock() + defer lc.clientsMutex.Unlock() + if clients, exists := lc.clients[c.ID]; exists { + for i, client := range clients { + if client == c { + // Remove the client from the slice + lc.clients[c.ID] = append(clients[:i], clients[i+1:]...) + + // If no more clients for this user, remove the entry entirely + if len(lc.clients[c.ID]) == 0 { + delete(lc.clients, c.ID) + } + + lc.lo.Debug("client removed from live chat", "client_id", c.ID) + return + } + } + } +} + +// BroadcastTypingToClients broadcasts typing status to specific widget clients for a conversation. +func (lc *LiveChat) BroadcastTypingToClients(conversationUUID string, contactID int, 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 + } + + // Only send to the specific contact's clients + contactIDStr := strconv.Itoa(contactID) + if clients, exists := lc.clients[contactIDStr]; exists { + for _, client := range clients { + select { + case client.Channel <- messageJSON: + lc.lo.Debug("typing status sent to widget client", "contact_id", contactID, "client_id", client.ID, "conversation_uuid", conversationUUID, "is_typing", isTyping) + default: + lc.lo.Warn("client channel full, dropping typing message", "contact_id", contactID, "client_id", client.ID) + } + } + } +} + +// BroadcastConversationToClients broadcasts conversation updates to specific widget clients. +func (lc *LiveChat) BroadcastConversationToClients(conversationUUID string, contactID int, conversationData interface{}) { + lc.clientsMutex.RLock() + defer lc.clientsMutex.RUnlock() + + // Create conversation update message for widget clients + conversationMessage := map[string]interface{}{ + "type": "conversation_update", + "data": map[string]interface{}{ + "conversation": conversationData, + }, + } + + messageJSON, err := json.Marshal(conversationMessage) + if err != nil { + lc.lo.Error("failed to marshal conversation update message", "error", err) + return + } + + // Only send to the specific contact's clients + contactIDStr := strconv.Itoa(contactID) + if clients, exists := lc.clients[contactIDStr]; exists { + for _, client := range clients { + select { + case client.Channel <- messageJSON: + lc.lo.Debug("conversation update sent to widget client", "contact_id", contactID, "client_id", client.ID, "conversation_uuid", conversationUUID) + default: + lc.lo.Warn("client channel full, dropping conversation update", "contact_id", contactID, "client_id", client.ID) + } + } + } +} diff --git a/internal/inbox/inbox.go b/internal/inbox/inbox.go index 78827c5d..8dd5075b 100644 --- a/internal/inbox/inbox.go +++ b/internal/inbox/inbox.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "sync" "github.com/abhinavxd/libredesk/internal/conversation/models" @@ -15,14 +16,17 @@ import ( "github.com/abhinavxd/libredesk/internal/dbutil" "github.com/abhinavxd/libredesk/internal/envelope" imodels "github.com/abhinavxd/libredesk/internal/inbox/models" + "github.com/abhinavxd/libredesk/internal/stringutil" umodels "github.com/abhinavxd/libredesk/internal/user/models" "github.com/jmoiron/sqlx" "github.com/knadh/go-i18n" + "github.com/volatiletech/null/v9" "github.com/zerodha/logf" ) const ( - ChannelEmail = "email" + ChannelEmail = "email" + ChannelLiveChat = "livechat" ) var ( @@ -70,6 +74,7 @@ type MessageStore interface { // UserStore defines methods for fetching user information. type UserStore interface { GetContact(id int, email string) (umodels.User, error) + GetAgent(id int, email string) (umodels.User, error) } // Opts contains the options for initializing the inbox manager. @@ -168,6 +173,9 @@ func (m *Manager) GetDBRecord(id int) (imodels.Inbox, error) { } inbox.Config = decryptedConfig + // Decrypt secret field + m.decryptInboxSecret(&inbox) + return inbox, nil } @@ -187,6 +195,9 @@ func (m *Manager) GetAll() ([]imodels.Inbox, error) { return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", m.i18n.P("globals.terms.inbox")), nil) } inboxes[i].Config = decryptedConfig + + // Decrypt secret field + m.decryptInboxSecret(&inboxes[i]) } return inboxes, nil @@ -194,6 +205,19 @@ func (m *Manager) GetAll() ([]imodels.Inbox, error) { // Create creates an inbox in the DB. func (m *Manager) Create(inbox imodels.Inbox) (imodels.Inbox, error) { + // Generate and encrypt secret for livechat inboxes if not provided + if inbox.Channel == ChannelLiveChat && !inbox.Secret.Valid { + secret, err := stringutil.RandomAlphanumeric(32) + if err != nil { + return imodels.Inbox{}, fmt.Errorf("generating inbox secret: %w", err) + } + encryptedSecret, err := crypto.Encrypt(secret, m.encryptionKey) + if err != nil { + return imodels.Inbox{}, fmt.Errorf("encrypting inbox secret: %w", err) + } + inbox.Secret = null.StringFrom(encryptedSecret) + } + // Encrypt sensitive fields before saving encryptedConfig, err := m.encryptInboxConfig(inbox.Config) if err != nil { @@ -202,7 +226,7 @@ func (m *Manager) Create(inbox imodels.Inbox) (imodels.Inbox, error) { } var createdInbox imodels.Inbox - if err := m.queries.InsertInbox.Get(&createdInbox, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.CSATEnabled); err != nil { + if err := m.queries.InsertInbox.Get(&createdInbox, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.Secret); err != nil { m.lo.Error("error creating inbox", "error", err) return imodels.Inbox{}, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.inbox}"), nil) } @@ -215,6 +239,9 @@ func (m *Manager) Create(inbox imodels.Inbox) (imodels.Inbox, error) { createdInbox.Config = decryptedConfig } + // Decrypt secret field + m.decryptInboxSecret(&createdInbox) + return createdInbox, nil } @@ -369,6 +396,18 @@ func (m *Manager) Update(id int, inbox imodels.Inbox) (imodels.Inbox, error) { return imodels.Inbox{}, err } inbox.Config = updatedConfig + case "livechat": + // Preserve existing secret if update contains password dummy + if inbox.Secret.Valid && strings.Contains(inbox.Secret.String, stringutil.PasswordDummy) { + inbox.Secret = current.Secret + } else if inbox.Secret.Valid && inbox.Secret.String != "" { + // Encrypt new secret + encryptedSecret, err := crypto.Encrypt(inbox.Secret.String, m.encryptionKey) + if err != nil { + return imodels.Inbox{}, fmt.Errorf("encrypting inbox secret: %w", err) + } + inbox.Secret = null.StringFrom(encryptedSecret) + } } // Encrypt sensitive fields before updating @@ -380,7 +419,7 @@ func (m *Manager) Update(id int, inbox imodels.Inbox) (imodels.Inbox, error) { // Update the inbox in the DB. var updatedInbox imodels.Inbox - if err := m.queries.Update.Get(&updatedInbox, id, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.Enabled); err != nil { + if err := m.queries.Update.Get(&updatedInbox, id, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.Enabled, inbox.Secret, inbox.LinkedEmailInboxID); err != nil { m.lo.Error("error updating inbox", "error", err) return imodels.Inbox{}, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.inbox}"), nil) } @@ -393,6 +432,9 @@ func (m *Manager) Update(id int, inbox imodels.Inbox) (imodels.Inbox, error) { updatedInbox.Config = decryptedConfig } + // Decrypt secret field + m.decryptInboxSecret(&updatedInbox) + return updatedInbox, nil } @@ -485,6 +527,9 @@ func (m *Manager) getActive() ([]imodels.Inbox, error) { return nil, fmt.Errorf("decrypting inbox config for ID %d: %w", inboxes[i].ID, err) } inboxes[i].Config = decryptedConfig + + // Decrypt secret field + m.decryptInboxSecret(&inboxes[i]) } return inboxes, nil @@ -615,3 +660,15 @@ func (m *Manager) decryptInboxConfig(config json.RawMessage) (json.RawMessage, e return decrypted, nil } + +// decryptInboxSecret decrypts the inbox secret field if present. +func (m *Manager) decryptInboxSecret(inbox *imodels.Inbox) { + if inbox.Secret.Valid && inbox.Secret.String != "" { + decrypted, err := crypto.Decrypt(inbox.Secret.String, m.encryptionKey) + if err != nil { + m.lo.Error("error decrypting inbox secret", "inbox_id", inbox.ID, "error", err) + return + } + inbox.Secret = null.StringFrom(decrypted) + } +} diff --git a/internal/inbox/models/models.go b/internal/inbox/models/models.go index 7874dd6f..763fc0a8 100644 --- a/internal/inbox/models/models.go +++ b/internal/inbox/models/models.go @@ -8,6 +8,7 @@ import ( "time" "github.com/abhinavxd/libredesk/internal/stringutil" + "github.com/volatiletech/null/v9" ) // Authentication type constants. @@ -18,15 +19,17 @@ const ( // Inbox represents a inbox record in DB. type Inbox struct { - ID int `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - Name string `db:"name" json:"name"` - Channel string `db:"channel" json:"channel"` - Enabled bool `db:"enabled" json:"enabled"` - CSATEnabled bool `db:"csat_enabled" json:"csat_enabled"` - From string `db:"from" json:"from"` - Config json.RawMessage `db:"config" json:"config"` + ID int `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Name string `db:"name" json:"name"` + Channel string `db:"channel" json:"channel"` + Enabled bool `db:"enabled" json:"enabled"` + CSATEnabled bool `db:"csat_enabled" json:"csat_enabled"` + From string `db:"from" json:"from"` + Config json.RawMessage `db:"config" json:"config"` + Secret null.String `db:"secret" json:"secret"` + LinkedEmailInboxID null.Int `db:"linked_email_inbox_id" json:"linked_email_inbox_id"` } // Config holds the email inbox configuration with multiple SMTP servers and IMAP clients. @@ -127,7 +130,11 @@ func (m *Inbox) ClearPasswords() error { } m.Config = clearedConfig - + case "livechat": + // Mask the secret field for livechat + if m.Secret.Valid && m.Secret.String != "" { + m.Secret = null.StringFrom(strings.Repeat(stringutil.PasswordDummy, 10)) + } default: return nil } diff --git a/internal/inbox/queries.sql b/internal/inbox/queries.sql index 2bbd477a..2803a775 100644 --- a/internal/inbox/queries.sql +++ b/internal/inbox/queries.sql @@ -6,21 +6,21 @@ SELECT id, created_at, updated_at, "name", deleted_at, channel, enabled, csat_en -- name: insert-inbox INSERT INTO inboxes -(channel, config, "name", "from", csat_enabled) -VALUES($1, $2, $3, $4, $5) +(channel, config, "name", "from", csat_enabled, secret) +VALUES($1, $2, $3, $4, $5, $6) RETURNING * -- name: get-inbox -SELECT id, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from" FROM inboxes where id = $1 and deleted_at is NULL; +SELECT id, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from", secret FROM inboxes where id = $1 and deleted_at is NULL; -- name: update UPDATE inboxes -set channel = $2, config = $3, "name" = $4, "from" = $5, csat_enabled = $6, enabled = $7, updated_at = now() +set channel = $2, config = $3, "name" = $4, "from" = $5, csat_enabled = $6, enabled = $7, secret = $8, linked_email_inbox_id = $9, updated_at = now() where id = $1 and deleted_at is NULL RETURNING *; -- name: soft-delete -UPDATE inboxes set deleted_at = now(), updated_at = now(), config = '{}' where id = $1 and deleted_at is NULL; +UPDATE inboxes set deleted_at = now(), updated_at = now(), config = '{}', enabled = false where id = $1 and deleted_at is NULL; -- name: toggle UPDATE inboxes diff --git a/internal/media/media.go b/internal/media/media.go index 5d19a7d6..7dfa6473 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -42,6 +42,14 @@ type Store interface { SignedURLValidator() func(name, sig string, exp int64) bool } +// 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) string + VerifySignature(name, signature string, expiresAt time.Time) bool +} + type Manager struct { store Store lo *logf.Logger @@ -178,6 +186,16 @@ func (m *Manager) GetURL(uuid, contentType, fileName string) string { return m.store.GetURL(uuid, disposition, fileName) } +// GetSignedURL generates a signed URL for secure media access if the store supports it. +// Returns a regular URL if the store doesn't support signed URLs. +func (m *Manager) GetSignedURL(name string) string { + if signedStore, ok := m.store.(SignedURLStore); ok { + return signedStore.GetSignedURL(name) + } + // Fallback to regular URL if signed URLs not supported + return m.GetURL(name, "", "") +} + // SignedURLValidator returns the store's signature validator if available. // Returns nil if the store doesn't support signed URL validation. func (m *Manager) SignedURLValidator() func(name, sig string, exp int64) bool { diff --git a/internal/media/stores/localfs/fs.go b/internal/media/stores/localfs/fs.go index e856583a..c90b81fa 100644 --- a/internal/media/stores/localfs/fs.go +++ b/internal/media/stores/localfs/fs.go @@ -3,11 +3,14 @@ package fs import ( "crypto/hmac" "crypto/sha256" + "crypto/subtle" "encoding/base64" "fmt" "io" + "net/url" "os" "path/filepath" + "strconv" "time" "github.com/abhinavxd/libredesk/internal/media" @@ -118,6 +121,57 @@ func (c *Client) Name() string { return "fs" } +// GetSignedURL generates a signed URL for the file with expiration. +// This implements the SignedURLStore interface for secure public access. +func (c *Client) GetSignedURL(name string) string { + // Generate base URL + baseURL := c.GetURL(name, "", "") + + // Create the signature payload: name + expires timestamp + expires := time.Now().Add(c.opts.Expiry).Unix() + payload := name + strconv.FormatInt(expires, 10) + + // Generate HMAC-SHA256 signature + h := hmac.New(sha256.New, []byte(c.opts.SigningKey)) + h.Write([]byte(payload)) + signature := base64.URLEncoding.EncodeToString(h.Sum(nil)) + + // Parse base URL and add query parameters + u, err := url.Parse(baseURL) + if err != nil { + // Fallback to base URL if parsing fails + return baseURL + } + + // Add signature and expires parameters + query := u.Query() + query.Set("signature", signature) + query.Set("expires", strconv.FormatInt(expires, 10)) + u.RawQuery = query.Encode() + + return u.String() +} + +// VerifySignature verifies that a signature is valid for the given parameters. +// This implements the SignedURLStore interface for secure public access. +func (c *Client) VerifySignature(name, signature string, expiresAt time.Time) bool { + // Check if URL has expired + if time.Now().After(expiresAt) { + return false + } + + // Recreate the signature payload: name + expires timestamp + expires := expiresAt.Unix() + payload := name + strconv.FormatInt(expires, 10) + + // Generate expected HMAC-SHA256 signature + h := hmac.New(sha256.New, []byte(c.opts.SigningKey)) + h.Write([]byte(payload)) + expectedSignature := base64.URLEncoding.EncodeToString(h.Sum(nil)) + + return subtle.ConstantTimeCompare([]byte(signature), []byte(expectedSignature)) == 1 +} + // getDir returns the current working directory path if no directory is specified, // else returns the directory path specified itself. func getDir(dir string) string { diff --git a/internal/migrations/v0.12.0.go b/internal/migrations/v0.12.0.go new file mode 100644 index 00000000..3e74f83f --- /dev/null +++ b/internal/migrations/v0.12.0.go @@ -0,0 +1,194 @@ +package migrations + +import ( + "github.com/jmoiron/sqlx" + "github.com/knadh/koanf/v2" + "github.com/knadh/stuffbin" +) + +// V0_12_0 updates the database schema to v0.12.0 (Live Chat feature). +func V0_12_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { + // 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(` + ALTER TABLE conversations DROP CONSTRAINT IF EXISTS conversations_contact_channel_id_fkey; + `) + if err != nil { + return err + } + + // Drop the contact_channel_id column from conversations table + _, err = db.Exec(` + ALTER TABLE conversations DROP COLUMN IF EXISTS contact_channel_id; + `) + if err != nil { + return err + } + + // Drop contact_channels table + _, err = db.Exec(` + DROP TABLE IF EXISTS contact_channels CASCADE; + `) + if err != nil { + return err + } + + // Add contact_last_seen_at column if it doesn't exist + _, err = db.Exec(` + ALTER TABLE conversations ADD COLUMN IF NOT EXISTS contact_last_seen_at TIMESTAMPTZ DEFAULT NOW(); + `) + if err != nil { + return err + } + + // Add last_interaction_at column if it doesn't exist + _, err = db.Exec(` + ALTER TABLE conversations ADD COLUMN IF NOT EXISTS last_interaction_at TIMESTAMPTZ NULL; + `) + if err != nil { + return err + } + + // Create index on last_interaction_at column if it doesn't exist + _, err = db.Exec(` + CREATE INDEX IF NOT EXISTS index_conversations_on_last_interaction_at ON conversations (last_interaction_at); + `) + if err != nil { + 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`, + + /* ── email for agents are unique ── */ + `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 + } + + tx2, err := db.Beginx() + if err != nil { + return err + } + defer tx2.Rollback() + + jwtStmts := []string{ + /* ── Add secret column to inboxes table for JWT signing (livechat only) ── */ + `ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS secret TEXT NULL`, + + /* ── Add external_user_id column to users table for 3rd party user mapping ── */ + `ALTER TABLE users ADD COLUMN IF NOT EXISTS external_user_id TEXT NULL`, + + /* ── ── */ + ` + CREATE UNIQUE INDEX IF NOT EXISTS index_unique_users_on_ext_id_when_type_is_contact + ON users (external_user_id) + WHERE type = 'contact' AND deleted_at IS NULL AND external_user_id IS NOT NULL; + `, + + ` + CREATE UNIQUE INDEX IF NOT EXISTS index_unique_users_on_email_when_no_ext_id_contact + ON users (email) + WHERE type = 'contact' AND deleted_at IS NULL AND external_user_id IS NULL; + `, + } + + for _, q := range jwtStmts { + if _, err = tx2.Exec(q); err != nil { + return err + } + } + + if err := tx2.Commit(); err != nil { + return err + } + + // Add index on conversation_messages for conversation_id and created_at + _, err = db.Exec(` + CREATE INDEX IF NOT EXISTS index_conversation_messages_on_conversation_id_and_created_at + ON conversation_messages (conversation_id, created_at); + `) + if err != nil { + return err + } + + // Add inbox linking support for conversation continuity between chat and email + _, err = db.Exec(` + ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS linked_email_inbox_id INT REFERENCES inboxes(id) ON DELETE SET NULL; + `) + if err != nil { + return err + } + + // Add column to track last continuity email sent + _, err = db.Exec(` + ALTER TABLE conversations ADD COLUMN IF NOT EXISTS last_continuity_email_sent_at TIMESTAMPTZ NULL; + `) + if err != nil { + return err + } + + // Add index for continuity email tracking + _, err = db.Exec(` + CREATE INDEX IF NOT EXISTS index_conversations_on_last_continuity_email_sent_at + ON conversations (last_continuity_email_sent_at); + `) + if err != nil { + return err + } + + // Add last_message_sender_id column to track who sent the last message + _, err = db.Exec(` + ALTER TABLE conversations ADD COLUMN IF NOT EXISTS last_message_sender_id BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE; + `) + if err != nil { + return err + } + + // Add last_interaction_sender_id column to track who sent the last interaction (for widget display) + _, err = db.Exec(` + ALTER TABLE conversations ADD COLUMN IF NOT EXISTS last_interaction_sender_id BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE; + `) + if err != nil { + return err + } + + return nil +} diff --git a/internal/ratelimit/ratelimit.go b/internal/ratelimit/ratelimit.go new file mode 100644 index 00000000..65f39d42 --- /dev/null +++ b/internal/ratelimit/ratelimit.go @@ -0,0 +1,97 @@ +package ratelimit + +import ( + "fmt" + "strconv" + "time" + + realip "github.com/ferluci/fast-realip" + "github.com/redis/go-redis/v9" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" +) + +// Config holds rate limiting configuration +type Config struct { + Widget WidgetConfig `toml:"widget"` +} + +// WidgetConfig holds widget-specific rate limiting configuration +type WidgetConfig struct { + Enabled bool `toml:"enabled"` + RequestsPerMinute int `toml:"requests_per_minute"` +} + +// Limiter handles rate limiting using Redis +type Limiter struct { + redis *redis.Client + config Config +} + +// New creates a new rate limiter +func New(redisClient *redis.Client, config Config) *Limiter { + return &Limiter{ + redis: redisClient, + config: config, + } +} + +// CheckWidgetLimit checks if the widget request should be rate limited +func (l *Limiter) CheckWidgetLimit(ctx *fasthttp.RequestCtx) error { + if !l.config.Widget.Enabled { + return nil + } + + clientIP := realip.FromRequest(ctx) + key := fmt.Sprintf("rate_limit:widget:%s", clientIP) + + // Use sliding window approach with Redis + now := time.Now().Unix() + windowStart := now - 60 // 60 seconds window + + // Get current count in the last minute + count, err := l.redis.ZCount(ctx, key, strconv.FormatInt(windowStart, 10), "+inf").Result() + if err != nil { + // Redis is down, allow request + return nil + } + + if count >= int64(l.config.Widget.RequestsPerMinute) { + // Set rate limit headers + ctx.Response.Header.Set("X-RateLimit-Limit", strconv.Itoa(l.config.Widget.RequestsPerMinute)) + ctx.Response.Header.Set("X-RateLimit-Remaining", "0") + ctx.Response.Header.Set("X-RateLimit-Reset", strconv.FormatInt(now+60, 10)) + ctx.Response.Header.Set("Retry-After", "60") + + ctx.SetStatusCode(fasthttp.StatusTooManyRequests) + ctx.SetBodyString(`{"status":"error","message":"Rate limit exceeded"}`) + return fmt.Errorf("rate limit exceeded") + } + + // Add current request to the sliding window + // Use nanoseconds as member to ensure uniqueness for multiple requests in same second + pipe := l.redis.Pipeline() + pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: time.Now().UnixNano()}) + pipe.ZRemRangeByScore(ctx, key, "-inf", strconv.FormatInt(windowStart, 10)) + pipe.Expire(ctx, key, time.Minute*2) // Set expiry to cleanup old keys + _, err = pipe.Exec(ctx) + if err != nil { + // Redis is down, allow request + return nil + } + + // Set rate limit headers for successful requests + remaining := max(l.config.Widget.RequestsPerMinute-int(count)-1, 0) + ctx.Response.Header.Set("X-RateLimit-Limit", strconv.Itoa(l.config.Widget.RequestsPerMinute)) + ctx.Response.Header.Set("X-RateLimit-Remaining", strconv.Itoa(remaining)) + ctx.Response.Header.Set("X-RateLimit-Reset", strconv.FormatInt(now+60, 10)) + + return nil +} + +// WidgetMiddleware returns a fastglue middleware for widget rate limiting +func (l *Limiter) WidgetMiddleware() func(*fastglue.Request) error { + return func(r *fastglue.Request) error { + return l.CheckWidgetLimit(r.RequestCtx) + } +} diff --git a/internal/report/queries.sql b/internal/report/queries.sql index 017c0ff7..4a9ec225 100644 --- a/internal/report/queries.sql +++ b/internal/report/queries.sql @@ -6,7 +6,7 @@ SELECT 'awaiting_response', COUNT( CASE - WHEN c.last_message_sender = 'contact' THEN 1 + WHEN COALESCE(c.meta->'last_message'->>'sender_type', c.last_message_sender) = 'contact' THEN 1 END ), 'unassigned', diff --git a/internal/stringutil/stringutil.go b/internal/stringutil/stringutil.go index 7efdbfcc..5e46553e 100644 --- a/internal/stringutil/stringutil.go +++ b/internal/stringutil/stringutil.go @@ -3,7 +3,6 @@ package stringutil import ( "crypto/rand" - "encoding/base64" "fmt" "net/mail" "net/url" @@ -23,6 +22,7 @@ const ( var ( regexpNonAlNum = regexp.MustCompile(`[^a-zA-Z0-9\-_\.]+`) regexpSpaces = regexp.MustCompile(`[\s]+`) + uuidV4Regex = regexp.MustCompile(`[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[89abAB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}`) regexpRefNumber = regexp.MustCompile(`#(\d+)`) regexpConvUUID = regexp.MustCompile(`(?i)\+conv-[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}@`) ) @@ -102,11 +102,11 @@ func RemoveEmpty(s []string) []string { return r } -// GenerateEmailMessageID generates a RFC-compliant Message-ID for an email, does not include the angle brackets. -// The client is expected to wrap the returned string in angle brackets. -func GenerateEmailMessageID(messageID string, fromAddress string) (string, error) { - if messageID == "" { - return "", fmt.Errorf("messageID cannot be empty") +// GenerateEmailMessageID generates an RFC-compliant Message-ID for an email without angle brackets. +// The uuid parameter is a unique identifier, typically a conversation UUID v4. +func GenerateEmailMessageID(uuid string, fromAddress string) (string, error) { + if uuid == "" { + return "", fmt.Errorf("uuid cannot be empty") } // Parse from address @@ -122,26 +122,16 @@ func GenerateEmailMessageID(messageID string, fromAddress string) (string, error } domain := parts[1] - // Generate cryptographic random component - random := make([]byte, 8) - if _, err := rand.Read(random); err != nil { - return "", fmt.Errorf("failed to generate random bytes: %w", err) - } - - // Sanitize messageID for email Message-ID - cleaner := regexp.MustCompile(`[^\w.-]`) // Allow only alphanum, ., -, _ - cleanmessageID := cleaner.ReplaceAllString(messageID, "_") - - // Ensure cleaned messageID isn't empty - if cleanmessageID == "" { - return "", fmt.Errorf("messageID became empty after sanitization") + // Random component + randomStr, err := RandomAlphanumeric(11) + if err != nil { + return "", fmt.Errorf("failed to generate random string: %w", err) } - // Build RFC-compliant Message-ID return fmt.Sprintf("%s-%d-%s@%s", - cleanmessageID, - time.Now().UnixNano(), // Nanosecond precision - strings.TrimRight(base64.URLEncoding.EncodeToString(random), "="), // URL-safe base64 without padding + uuid, + time.Now().UnixNano(), + randomStr, domain, ), nil } @@ -289,6 +279,12 @@ func ComputeRecipients( return } +// ExtractUUID finds and returns the first valid UUID v4 in the given text. +// Returns empty string if no valid UUID is found. +func ExtractUUID(text string) string { + return uuidV4Regex.FindString(text) +} + // ExtractReferenceNumber extracts the last reference number from a subject line. // For example, "RE: Test - #392" returns "392". // If multiple numbers exist (e.g., "Order #123 - #392"), returns the last one ("392"). diff --git a/internal/stringutil/uuid_test.go b/internal/stringutil/uuid_test.go new file mode 100644 index 00000000..e49ef9ce --- /dev/null +++ b/internal/stringutil/uuid_test.go @@ -0,0 +1,126 @@ +package stringutil + +import "testing" + +func TestExtractUUID(t *testing.T) { + testCases := []struct { + name string + input string + expected string + }{ + { + name: "Valid UUID v4 in Message-ID format", + input: "<550e8400-e29b-41d4-a716-446655440000.1735555200000000000@example.com>", + expected: "550e8400-e29b-41d4-a716-446655440000", + }, + { + name: "Valid UUID v4 in plain text", + input: "some text 123e4567-e89b-42d3-a456-426614174000 more text", + expected: "123e4567-e89b-42d3-a456-426614174000", + }, + { + name: "Valid UUID v4 with uppercase", + input: "550E8400-E29B-41D4-A716-446655440000", + expected: "550E8400-E29B-41D4-A716-446655440000", + }, + { + name: "Valid UUID v4 mixed case", + input: "550e8400-E29B-41d4-A716-446655440000", + expected: "550e8400-E29B-41d4-A716-446655440000", + }, + { + name: "Multiple UUIDs returns first valid one", + input: "first: 550e8400-e29b-41d4-a716-446655440000 second: 123e4567-e89b-42d3-a456-426614174000", + expected: "550e8400-e29b-41d4-a716-446655440000", + }, + { + name: "No UUID in text", + input: "no uuid here just random text", + expected: "", + }, + { + name: "Empty string", + input: "", + expected: "", + }, + { + name: "Invalid UUID - wrong length", + input: "550e8400-e29b-41d4-a716-44665544000", // missing last char + expected: "", + }, + { + name: "Invalid UUID - wrong version (not v4)", + input: "550e8400-e29b-31d4-a716-446655440000", // version 3, not 4 + expected: "", + }, + { + name: "Invalid UUID - wrong variant", + input: "550e8400-e29b-41d4-c716-446655440000", // variant C instead of A/B/8/9 + expected: "", + }, + { + name: "Invalid UUID - non-hex characters", + input: "550e8400-e29b-41d4-a716-44665544000g", // 'g' is not hex + expected: "", + }, + { + name: "UUID-like but wrong format", + input: "550e8400_e29b_41d4_a716_446655440000", // underscores instead of hyphens + expected: "", + }, + { + name: "Valid UUID in email References header", + input: "<550e8400-e29b-41d4-a716-446655440000.12345@domain.com> ", + expected: "550e8400-e29b-41d4-a716-446655440000", + }, + { + name: "Valid UUID with variant 8", + input: "550e8400-e29b-41d4-8716-446655440000", // variant 8 + expected: "550e8400-e29b-41d4-8716-446655440000", + }, + { + name: "Valid UUID with variant 9", + input: "550e8400-e29b-41d4-9716-446655440000", // variant 9 + expected: "550e8400-e29b-41d4-9716-446655440000", + }, + { + name: "Valid UUID with variant A", + input: "550e8400-e29b-41d4-A716-446655440000", // variant A + expected: "550e8400-e29b-41d4-A716-446655440000", + }, + { + name: "Valid UUID with variant B", + input: "550e8400-e29b-41d4-B716-446655440000", // variant B + expected: "550e8400-e29b-41d4-B716-446655440000", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := ExtractUUID(tc.input) + if result != tc.expected { + t.Errorf("ExtractUUID(%q) = %q; expected %q", tc.input, result, tc.expected) + } + }) + } +} + +// BenchmarkExtractUUID benchmarks the UUID extraction function +func BenchmarkExtractUUID(b *testing.B) { + testString := "<550e8400-e29b-41d4-a716-446655440000.1735555200000000000@example.com>" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ExtractUUID(testString) + } +} + +// BenchmarkExtractUUIDNoMatch benchmarks when no UUID is found +func BenchmarkExtractUUIDNoMatch(b *testing.B) { + testString := "no uuid in this string at all, just random text and numbers 12345" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ExtractUUID(testString) + } +} \ No newline at end of file diff --git a/internal/user/agent.go b/internal/user/agent.go index a9a03337..9218b9b2 100644 --- a/internal/user/agent.go +++ b/internal/user/agent.go @@ -29,7 +29,7 @@ func (u *Manager) MonitorAgentAvailability(ctx context.Context) { // GetAgent retrieves an agent by ID and also caches it for future requests. func (u *Manager) GetAgent(id int, email string) (models.User, error) { - agent, err := u.Get(id, email, models.UserTypeAgent) + agent, err := u.Get(id, email, []string{models.UserTypeAgent}) if err != nil { return models.User{}, err } @@ -102,7 +102,7 @@ func (u *Manager) CreateAgent(firstName, lastName, email string, roles []string) u.lo.Error("error creating user", "error", err) return models.User{}, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil) } - return u.Get(id, "", models.UserTypeAgent) + return u.Get(id, "", []string{models.UserTypeAgent}) } // UpdateAgent updates an agent with individual field parameters @@ -169,5 +169,5 @@ func (u *Manager) markInactiveAgentsOffline() { // GetAllAgents returns a list of all agents. func (u *Manager) GetAgents() ([]models.UserCompact, error) { // Some dirty hack. - return u.GetAllUsers(1, 999999999, models.UserTypeAgent, "desc", "users.updated_at", "") + return u.GetAllUsers(1, 999999999, []string{models.UserTypeAgent}, "desc", "users.updated_at", "") } diff --git a/internal/user/contact.go b/internal/user/contact.go index e8908490..2963e8f1 100644 --- a/internal/user/contact.go +++ b/internal/user/contact.go @@ -20,7 +20,16 @@ func (u *Manager) CreateContact(user *models.User) error { // Normalize email address. user.Email = null.NewString(strings.ToLower(user.Email.String), user.Email.Valid) - if err := u.q.InsertContact.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL, user.InboxID, user.SourceChannelID).Scan(&user.ID, &user.ContactChannelID); err != nil { + // If external_user_id is provided, insert with it. + if user.ExternalUserID.Valid { + if err := u.q.InsertContactWithExtID.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL, user.ExternalUserID, user.CustomAttributes).Scan(&user.ID); err != nil { + u.lo.Error("error inserting contact with external ID", "error", err) + return fmt.Errorf("insert contact with external ID: %w", err) + } + return nil + } + // Insert without external_user_id. + if err := u.q.InsertContactNoExtID.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL).Scan(&user.ID); err != nil { u.lo.Error("error inserting contact", "error", err) return fmt.Errorf("insert contact: %w", err) } @@ -38,7 +47,7 @@ func (u *Manager) UpdateContact(id int, user models.User) error { // GetContact retrieves a contact by ID. func (u *Manager) GetContact(id int, email string) (models.User, error) { - return u.Get(id, email, models.UserTypeContact) + return u.Get(id, email, []string{models.UserTypeContact, models.UserTypeVisitor}) } // GetAllContacts returns a list of all contacts. @@ -52,5 +61,5 @@ func (u *Manager) GetContacts(page, pageSize int, order, orderBy string, filters if pageSize < 1 { pageSize = 10 } - return u.GetAllUsers(page, pageSize, models.UserTypeContact, order, orderBy, filtersJSON) + return u.GetAllUsers(page, pageSize, []string{models.UserTypeContact, models.UserTypeVisitor}, order, orderBy, filtersJSON) } diff --git a/internal/user/models/models.go b/internal/user/models/models.go index 18dcb5b9..2110478a 100644 --- a/internal/user/models/models.go +++ b/internal/user/models/models.go @@ -19,6 +19,7 @@ const ( // User types UserTypeAgent = "agent" UserTypeContact = "contact" + UserTypeVisitor = "visitor" // User availability statuses Online = "online" @@ -63,6 +64,8 @@ type User struct { Roles pq.StringArray `db:"roles" json:"roles"` Permissions pq.StringArray `db:"permissions" json:"permissions"` CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"` + Meta json.RawMessage `db:"meta" json:"meta"` + ExternalUserID null.String `db:"external_user_id" json:"external_user_id"` Teams tmodels.TeamsCompact `db:"teams" json:"teams"` ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"` NewPassword string `db:"-" json:"new_password,omitempty"` @@ -77,6 +80,17 @@ type User struct { APISecret null.String `db:"api_secret" json:"-"` } +// 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 440bddd2..c0837f6a 100644 --- a/internal/user/queries.sql +++ b/internal/user/queries.sql @@ -43,6 +43,7 @@ SELECT u.phone_number, u.api_key, u.api_key_last_used_at, + u.external_user_id, u.api_secret, array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL) AS roles, COALESCE( @@ -57,7 +58,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 (cardinality($3::text[]) = 0 OR u.type::text = ANY($3::text[])) GROUP BY u.id; -- name: set-user-password @@ -97,6 +101,12 @@ SET custom_attributes = $2, updated_at = now() WHERE id = $1; +-- name: upsert-custom-attributes +UPDATE users +SET custom_attributes = COALESCE(custom_attributes, '{}'::jsonb) || $2, +updated_at = now() +WHERE id = $1 + -- name: update-avatar UPDATE users SET avatar_url = $2, updated_at = now() @@ -143,18 +153,24 @@ FROM inserted_user, unnest($6::text[]) role_name JOIN roles r ON r.name = role_name RETURNING user_id; --- name: insert-contact -WITH contact AS ( - 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 - DO UPDATE SET updated_at = now() - RETURNING id -) -INSERT INTO contact_channels (contact_id, inbox_id, identifier) -VALUES ((SELECT id FROM contact), $6, $7) -ON CONFLICT (contact_id, inbox_id) DO UPDATE SET updated_at = now() -RETURNING contact_id, id; +-- name: insert-contact-with-external-id +INSERT INTO users (email, type, first_name, last_name, "password", avatar_url, external_user_id, custom_attributes) +VALUES ($1, 'contact', $2, $3, $4, $5, $6, $7) +ON CONFLICT (external_user_id) WHERE type = 'contact' AND deleted_at IS NULL AND external_user_id IS NOT NULL +DO UPDATE SET updated_at = now() +RETURNING id; + +-- name: insert-contact-without-external-id +INSERT INTO users (email, type, first_name, last_name, "password", avatar_url, external_user_id) +VALUES ($1, 'contact', $2, $3, $4, $5, NULL) +ON CONFLICT (email) WHERE type = 'contact' AND deleted_at IS NULL AND external_user_id IS NULL +DO UPDATE SET updated_at = now() +RETURNING id; + +-- name: insert-visitor +INSERT INTO users (email, type, first_name, last_name, custom_attributes) +VALUES ($1, 'visitor', $2, $3, $4) +RETURNING *; -- name: update-last-login-at UPDATE users @@ -176,7 +192,7 @@ SET first_name = COALESCE($2, first_name), phone_number = $6, phone_number_country_code = $7, updated_at = now() -WHERE id = $1 and type = 'contact'; +WHERE id = $1 and type in ('contact', 'visitor'); -- name: get-notes SELECT @@ -238,6 +254,7 @@ SELECT u.api_key, u.api_key_last_used_at, u.api_secret, + u.external_user_id, array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL) AS roles, COALESCE( (SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji)) @@ -267,4 +284,42 @@ WHERE id = $1; -- name: update-api-key-last-used UPDATE users SET api_key_last_used_at = now() -WHERE id = $1; \ No newline at end of file +WHERE id = $1; + +-- name: get-user-by-external-id +SELECT + u.id, + u.created_at, + u.updated_at, + u.email, + u.password, + u.type, + u.enabled, + u.avatar_url, + u.first_name, + u.last_name, + u.availability_status, + u.last_active_at, + u.last_login_at, + u.phone_number_country_code, + u.phone_number, + u.external_user_id, + u.custom_attributes, + u.api_key, + u.api_key_last_used_at, + array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL) AS roles, + COALESCE( + (SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji)) + FROM team_members tm + JOIN teams t ON tm.team_id = t.id + WHERE tm.user_id = u.id), + '[]' + ) AS teams, + array_agg(DISTINCT p ORDER BY p) FILTER (WHERE p IS NOT NULL) AS permissions +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.deleted_at IS NULL + AND u.external_user_id = $1 +GROUP BY u.id; diff --git a/internal/user/user.go b/internal/user/user.go index c68d8921..be098df7 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -64,10 +64,12 @@ type queries struct { GetUser *sqlx.Stmt `query:"get-user"` GetNotes *sqlx.Stmt `query:"get-notes"` GetNote *sqlx.Stmt `query:"get-note"` + GetUserByExternalID *sqlx.Stmt `query:"get-user-by-external-id"` GetUsersCompact string `query:"get-users-compact"` UpdateContact *sqlx.Stmt `query:"update-contact"` UpdateAgent *sqlx.Stmt `query:"update-agent"` UpdateCustomAttributes *sqlx.Stmt `query:"update-custom-attributes"` + UpsertCustomAttributes *sqlx.Stmt `query:"upsert-custom-attributes"` UpdateAvatar *sqlx.Stmt `query:"update-avatar"` UpdateAvailability *sqlx.Stmt `query:"update-availability"` UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"` @@ -79,8 +81,10 @@ type queries struct { SetPassword *sqlx.Stmt `query:"set-password"` DeleteNote *sqlx.Stmt `query:"delete-note"` InsertAgent *sqlx.Stmt `query:"insert-agent"` - InsertContact *sqlx.Stmt `query:"insert-contact"` + InsertContactWithExtID *sqlx.Stmt `query:"insert-contact-with-external-id"` + InsertContactNoExtID *sqlx.Stmt `query:"insert-contact-without-external-id"` InsertNote *sqlx.Stmt `query:"insert-note"` + InsertVisitor *sqlx.Stmt `query:"insert-visitor"` ToggleEnable *sqlx.Stmt `query:"toggle-enable"` // API key queries GetUserByAPIKey *sqlx.Stmt `query:"get-user-by-api-key"` @@ -107,7 +111,7 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) { // VerifyPassword authenticates an user by email and password, returning the user if successful. func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) { var user models.User - if err := u.q.GetUser.Get(&user, 0, email, models.UserTypeAgent); err != nil { + if err := u.q.GetUser.Get(&user, 0, email, pq.Array([]string{models.UserTypeAgent})); err != nil { if errors.Is(err, sql.ErrNoRows) { return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil) } @@ -121,8 +125,8 @@ func (u *Manager) VerifyPassword(email string, password []byte) (models.User, er } // GetAllUsers returns a list of all users. -func (u *Manager) GetAllUsers(page, pageSize int, userType, order, orderBy string, filtersJSON string) ([]models.UserCompact, error) { - query, qArgs, err := u.makeUserListQuery(page, pageSize, userType, order, orderBy, filtersJSON) +func (u *Manager) GetAllUsers(page, pageSize int, userTypes []string, order, orderBy string, filtersJSON string) ([]models.UserCompact, error) { + query, qArgs, err := u.makeUserListQuery(page, pageSize, userTypes, order, orderBy, filtersJSON) if err != nil { u.lo.Error("error creating user list query", "error", err) return nil, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil) @@ -148,10 +152,14 @@ func (u *Manager) GetAllUsers(page, pageSize int, userType, order, orderBy strin return users, nil } -// Get retrieves an user by ID or email. -func (u *Manager) Get(id int, email, type_ string) (models.User, error) { +// Get retrieves an user by ID or email or type. At least one of ID or email must be provided. +func (u *Manager) Get(id int, email string, userType []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 err := u.q.GetUser.Get(&user, id, email, pq.Array(userType)); err != nil { if errors.Is(err, sql.ErrNoRows) { return user, envelope.NewError(envelope.NotFoundError, u.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.user}"), nil) } @@ -163,7 +171,20 @@ func (u *Manager) Get(id int, email, type_ string) (models.User, error) { // GetSystemUser retrieves the system user. func (u *Manager) GetSystemUser() (models.User, error) { - return u.Get(0, models.SystemUserEmail, models.UserTypeAgent) + return u.Get(0, models.SystemUserEmail, []string{models.UserTypeAgent}) +} + +// GetByExternalID retrieves a user by external user ID. +func (u *Manager) GetByExternalID(externalUserID string) (models.User, error) { + var user models.User + if err := u.q.GetUserByExternalID.Get(&user, externalUserID); err != nil { + if err == sql.ErrNoRows { + return user, envelope.NewError(envelope.NotFoundError, u.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.user}"), nil) + } + u.lo.Error("error fetching user by external ID", "external_user_id", externalUserID, "error", err) + return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil) + } + return user, nil } // UpdateAvatar updates the user avatar. @@ -239,19 +260,23 @@ func (u *Manager) UpdateLastActive(id int) error { return nil } -// UpdateCustomAttributes updates the custom attributes of an user. -func (u *Manager) UpdateCustomAttributes(id int, customAttributes map[string]any) error { - // Convert custom attributes to JSON. +// SaveCustomAttributes sets or merges custom attributes for a user. +// If replace is true, existing attributes are overwritten. Otherwise, attributes are merged. +func (u *Manager) SaveCustomAttributes(id int, customAttributes map[string]any, replace bool) error { jsonb, err := json.Marshal(customAttributes) if err != nil { - u.lo.Error("error marshalling custom attributes to JSON", "error", err) + u.lo.Error("error marshalling custom attributes", "error", err) return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil) } - // Update custom attributes in the database. - if _, err := u.q.UpdateCustomAttributes.Exec(id, jsonb); err != nil { - u.lo.Error("error updating user custom attributes", "error", err) + var execErr error + if replace { + _, execErr = u.q.UpdateCustomAttributes.Exec(id, jsonb) + } else { + _, execErr = u.q.UpsertCustomAttributes.Exec(id, jsonb) + } + if execErr != nil { + u.lo.Error("error saving custom attributes", "error", execErr) return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil) - } return nil } @@ -434,9 +459,9 @@ func updateSystemUserPassword(db *sqlx.DB, hashedPassword []byte) error { } // makeUserListQuery generates a query to fetch users based on the provided filters. -func (u *Manager) makeUserListQuery(page, pageSize int, typ, order, orderBy, filtersJSON string) (string, []interface{}, error) { +func (u *Manager) makeUserListQuery(page, pageSize int, userTypes []string, order, orderBy, filtersJSON string) (string, []interface{}, error) { var qArgs []any - qArgs = append(qArgs, pq.Array([]string{typ})) + qArgs = append(qArgs, pq.Array(userTypes)) return dbutil.BuildPaginatedQuery(u.q.GetUsersCompact, qArgs, dbutil.PaginationOptions{ Order: order, OrderBy: orderBy, diff --git a/internal/user/visitor.go b/internal/user/visitor.go new file mode 100644 index 00000000..f22f30c4 --- /dev/null +++ b/internal/user/visitor.go @@ -0,0 +1,32 @@ +package user + +import ( + "fmt" + "strings" + + "github.com/abhinavxd/libredesk/internal/user/models" + "github.com/taion809/haikunator" + "github.com/volatiletech/null/v9" +) + +// CreateVisitor creates a new visitor user. +func (u *Manager) CreateVisitor(user *models.User) error { + // Normalize email address. + user.Email = null.NewString(strings.ToLower(user.Email.String), user.Email.Valid) + + if user.FirstName == "" && user.LastName == "" { + h := haikunator.NewHaikunator() + user.FirstName = h.Haikunate() + } + + if err := u.q.InsertVisitor.Get(user, user.Email, user.FirstName, user.LastName, user.CustomAttributes); err != nil { + u.lo.Error("error inserting contact", "error", err) + return fmt.Errorf("insert contact: %w", err) + } + return nil +} + +// GetVisitor retrieves a visitor user by ID +func (u *Manager) GetVisitor(id int) (models.User, error) { + return u.Get(id, "", []string{models.UserTypeVisitor}) +} diff --git a/internal/ws/client.go b/internal/ws/client.go index 1a140fa2..ee7e6b3b 100644 --- a/internal/ws/client.go +++ b/internal/ws/client.go @@ -14,7 +14,7 @@ import ( // SafeBool is a thread-safe boolean. type SafeBool struct { flag bool - mu sync.Mutex + mu sync.RWMutex } // Set sets the value of the SafeBool. @@ -26,8 +26,8 @@ func (b *SafeBool) Set(value bool) { // Get returns the value of the SafeBool. func (b *SafeBool) Get() bool { - b.mu.Lock() - defer b.mu.Unlock() + b.mu.RLock() + defer b.mu.RUnlock() return b.flag } @@ -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 d4ba9b20..8234bd5c 100644 --- a/internal/ws/models/models.go +++ b/internal/ws/models/models.go @@ -8,6 +8,9 @@ const ( MessageTypeNewConversation = "new_conversation" MessageTypeNewNotification = "new_notification" MessageTypeError = "error" + MessageTypeConversationSubscribe = "conversation_subscribe" + MessageTypeConversationSubscribed = "conversation_subscribed" + MessageTypeTyping = "typing" ) // WSMessage represents a WS message. @@ -27,3 +30,16 @@ 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"` + IsPrivateMessage bool `json:"is_private_message"` + UserID int `json:"user_id"` +} diff --git a/internal/ws/ws.go b/internal/ws/ws.go index 8a6b170b..f90fa4f6 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" @@ -12,24 +13,42 @@ import ( type Hub struct { // Client ID to WS Client map, user can connect from multiple devices and each device will have a separate client. clients map[int][]*Client - clientsMutex sync.Mutex + 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.Mutex{}, - 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 { @@ -54,8 +79,8 @@ func (h *Hub) RemoveClient(client *Client) { // BroadcastMessage broadcasts a message to the specified users. // If no users are specified, the message is broadcast to all users. func (h *Hub) BroadcastMessage(msg models.BroadcastMessage) { - h.clientsMutex.Lock() - defer h.clientsMutex.Unlock() + h.clientsMutex.RLock() + defer h.clientsMutex.RUnlock() // Broadcast to all users if no users are specified. if len(msg.Users) == 0 { @@ -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 && !typingMsg.IsPrivateMessage { + 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 ec756302..381a4b7c 100644 --- a/schema.sql +++ b/schema.sql @@ -1,13 +1,14 @@ CREATE EXTENSION IF NOT EXISTS pg_trgm; -DROP TYPE IF EXISTS "channels" CASCADE; CREATE TYPE "channels" AS ENUM ('email'); +DROP TYPE IF EXISTS "channels" CASCADE; CREATE TYPE "channels" AS ENUM ('email', 'livechat'); DROP TYPE IF EXISTS "message_type" CASCADE; CREATE TYPE "message_type" AS ENUM ('incoming','outgoing','activity'); DROP TYPE IF EXISTS "message_sender_type" CASCADE; CREATE TYPE "message_sender_type" AS ENUM ('agent','contact'); DROP TYPE IF EXISTS "message_status" CASCADE; CREATE TYPE "message_status" AS ENUM ('received','sent','failed','pending'); DROP TYPE IF EXISTS "content_type" CASCADE; CREATE TYPE "content_type" AS ENUM ('text','html'); DROP TYPE IF EXISTS "conversation_assignment_type" CASCADE; CREATE TYPE "conversation_assignment_type" AS ENUM ('Round robin','Manual'); DROP TYPE IF EXISTS "template_type" CASCADE; CREATE TYPE "template_type" AS ENUM ('email_outgoing', 'email_notification'); -DROP TYPE IF EXISTS "user_type" CASCADE; CREATE TYPE "user_type" AS ENUM ('agent', 'contact'); +-- Visitors are unauthenticated contacts. +DROP TYPE IF EXISTS "user_type" CASCADE; CREATE TYPE "user_type" AS ENUM ('agent', 'contact', 'visitor'); DROP TYPE IF EXISTS "ai_provider" CASCADE; CREATE TYPE "ai_provider" AS ENUM ('openai'); DROP TYPE IF EXISTS "automation_execution_mode" CASCADE; CREATE TYPE "automation_execution_mode" AS ENUM ('all', 'first_match'); DROP TYPE IF EXISTS "macro_visibility" CASCADE; CREATE TYPE "macro_visibility" AS ENUM ('all', 'team', 'user'); @@ -84,6 +85,8 @@ CREATE TABLE inboxes ( csat_enabled bool DEFAULT false NOT NULL, config jsonb DEFAULT '{}'::jsonb NOT NULL, "from" TEXT NULL, + secret TEXT NULL, + linked_email_inbox_id INT REFERENCES inboxes(id) ON DELETE SET NULL, CONSTRAINT constraint_inboxes_on_name CHECK (length("name") <= 140) ); @@ -137,6 +140,7 @@ CREATE TABLE users ( "password" VARCHAR(150) NULL, avatar_url TEXT NULL, custom_attributes JSONB DEFAULT '{}'::jsonb NOT NULL, + external_user_id TEXT NULL, reset_password_token TEXT NULL, reset_password_token_expiry TIMESTAMPTZ NULL, availability_status user_availability_status DEFAULT 'offline' NOT NULL, @@ -153,10 +157,17 @@ 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 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); +CREATE UNIQUE INDEX index_unique_users_on_email_when_type_is_agent + ON users(email) + WHERE type = 'agent' AND deleted_at IS NULL; +CREATE UNIQUE INDEX index_unique_users_on_ext_id_when_type_is_contact + ON users (external_user_id) + WHERE type = 'contact' AND deleted_at IS NULL AND external_user_id IS NOT NULL; +CREATE UNIQUE INDEX index_unique_users_on_email_when_no_ext_id_contact + ON users (email) + WHERE type = 'contact' AND deleted_at IS NULL AND external_user_id IS NULL; DROP TABLE IF EXISTS user_roles CASCADE; CREATE TABLE user_roles ( @@ -188,21 +199,6 @@ CREATE TABLE conversation_priorities ( "name" TEXT NOT NULL UNIQUE ); -DROP TABLE IF EXISTS contact_channels CASCADE; -CREATE TABLE contact_channels ( - id SERIAL PRIMARY KEY, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - - -- Cascade deletes when contact or inbox is deleted. - contact_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, - inbox_id INT NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE ON UPDATE CASCADE, - - identifier TEXT NOT NULL, - CONSTRAINT constraint_contact_channels_on_identifier CHECK (length(identifier) <= 1000), - CONSTRAINT constraint_contact_channels_on_inbox_id_and_contact_id_unique UNIQUE (inbox_id, contact_id) -); - DROP TABLE IF EXISTS conversations CASCADE; CREATE TABLE conversations ( id BIGSERIAL PRIMARY KEY, @@ -225,12 +221,13 @@ CREATE TABLE conversations ( inbox_id INT REFERENCES inboxes(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, -- Restrict delete. - contact_channel_id INT REFERENCES contact_channels(id) ON DELETE RESTRICT ON UPDATE CASCADE NOT NULL, status_id INT REFERENCES conversation_statuses(id) ON DELETE RESTRICT ON UPDATE CASCADE NOT NULL, priority_id INT REFERENCES conversation_priorities(id) ON DELETE RESTRICT ON UPDATE CASCADE, meta JSONB DEFAULT '{}'::jsonb NOT NULL, custom_attributes JSONB DEFAULT '{}'::jsonb NOT NULL, + assignee_last_seen_at TIMESTAMPTZ DEFAULT NOW(), + contact_last_seen_at TIMESTAMPTZ DEFAULT NOW(), first_reply_at TIMESTAMPTZ NULL, last_reply_at TIMESTAMPTZ NULL, closed_at TIMESTAMPTZ NULL, @@ -241,11 +238,14 @@ CREATE TABLE conversations ( last_message_at TIMESTAMPTZ NULL, last_message TEXT NULL, last_message_sender message_sender_type NULL, + last_message_sender_id BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, last_interaction TEXT NULL, last_interaction_sender message_sender_type NULL, + last_interaction_sender_id BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, last_interaction_at TIMESTAMPTZ NULL, next_sla_deadline_at TIMESTAMPTZ NULL, - snoozed_until TIMESTAMPTZ NULL + snoozed_until TIMESTAMPTZ NULL, + last_continuity_email_sent_at TIMESTAMPTZ NULL ); CREATE INDEX index_conversations_on_assigned_user_id ON conversations (assigned_user_id); CREATE INDEX index_conversations_on_assigned_team_id ON conversations (assigned_team_id); @@ -259,6 +259,7 @@ CREATE INDEX index_conversations_on_last_message_at ON conversations (last_messa CREATE INDEX index_conversations_on_last_interaction_at ON conversations (last_interaction_at); CREATE INDEX index_conversations_on_next_sla_deadline_at ON conversations (next_sla_deadline_at); CREATE INDEX index_conversations_on_waiting_since ON conversations (waiting_since); +CREATE INDEX index_conversations_on_last_continuity_email_sent_at ON conversations (last_continuity_email_sent_at); DROP TABLE IF EXISTS conversation_messages CASCADE; CREATE TABLE conversation_messages ( @@ -283,6 +284,7 @@ CREATE INDEX index_conversation_messages_on_conversation_id ON conversation_mess CREATE INDEX index_conversation_messages_on_created_at ON conversation_messages (created_at); CREATE INDEX index_conversation_messages_on_source_id ON conversation_messages (source_id); CREATE INDEX index_conversation_messages_on_status ON conversation_messages (status); +CREATE INDEX index_conversation_messages_on_conversation_id_and_created_at ON conversation_messages (conversation_id, created_at); DROP TABLE IF EXISTS automation_rules CASCADE; CREATE TABLE automation_rules ( diff --git a/static/widget.js b/static/widget.js new file mode 100644 index 00000000..25e03076 --- /dev/null +++ b/static/widget.js @@ -0,0 +1,460 @@ +/** + * Libredesk Chat Widget + * Embeddable chat widget for websites + */ +(function () { + 'use strict'; + + // Prevent multiple initializations + if (window.LibredeskWidget && window.LibredeskWidget instanceof Function) { + return; + } + + class LibredeskWidget { + constructor(config = {}) { + // Validate required config + if (!config.baseUrl) { + throw new Error('baseUrl is required'); + } + if (!config.inboxID) { + throw new Error('inboxID is required'); + } + + 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.isExpanded = false; + this.isVueAppReady = false; + this.init(); + } + + async init () { + try { + await this.fetchWidgetSettings(); + this.createElements(); + this.setLauncherPosition(); + // Hide widget initially until Vue app is ready + this.widgetButtonWrapper.style.display = 'none'; + this.iframe.addEventListener('load', () => { + setTimeout(() => { + this.sendMobileState(); + }, 2000); + }); + this.setupMobileDetection(); + this.setupEventListeners(); + } catch (error) { + console.error('Failed to initialize Libredesk Widget:', error); + } + } + + async fetchWidgetSettings () { + try { + const response = await fetch(`${this.config.baseUrl}/api/v1/widget/chat/settings/launcher?inbox_id=${this.config.inboxID}`); + + if (!response.ok) { + throw new Error(`Error fetching widget settings. Status: ${response.status}`); + } + + const result = await response.json(); + + if (result.status !== 'success') { + throw new Error('Failed to fetch widget settings'); + } + + this.widgetSettings = result.data; + } catch (error) { + console.error('Error fetching widget settings:', error); + throw error; + } + } + + // Create launcher and iframe elements. + createElements () { + const launcher = this.widgetSettings.launcher; + const colors = this.widgetSettings.colors; + + // Create toggle button + this.toggleButton = document.createElement('div'); + this.toggleButton.style.cssText = ` + position: fixed; + cursor: pointer; + z-index: 9999; + width: 60px; + height: 60px; + background-color: ${colors.primary}; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + box-shadow: 0 5px 20px rgba(0,0,0,0.3); + transition: transform 0.3s ease; + `; + + // Create icon element or arrow based on state + this.iconContainer = document.createElement('div'); + this.iconContainer.style.cssText = ` + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + transition: transform 0.3s ease; + `; + + if (launcher.logo_url) { + this.defaultIcon = document.createElement('img'); + this.defaultIcon.src = launcher.logo_url; + this.defaultIcon.style.cssText = ` + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + `; + this.iconContainer.appendChild(this.defaultIcon); + } + + // Create downward arrow SVG + this.arrowIcon = document.createElement('div'); + this.arrowIcon.innerHTML = ` + + + + `; + this.arrowIcon.style.cssText = ` + width: 100%; + height: 100%; + display: none; + justify-content: center; + align-items: center; + `; + this.iconContainer.appendChild(this.arrowIcon); + + this.toggleButton.appendChild(this.iconContainer); + + // 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}`; + this.iframe.style.cssText = ` + position: fixed; + border: none; + border-radius: 12px; + box-shadow: 0 5px 80px rgba(0,0,0,0.3); + z-index: 9999; + width: 400px; + height: 700px; + transition: all 0.3s ease; + display: none; + `; + + document.body.appendChild(this.widgetButtonWrapper); + document.body.appendChild(this.iframe); + } + + 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 () { + const launcher = this.widgetSettings.launcher; + const spacing = launcher.spacing; + const position = launcher.position; + const side = position === 'right' ? 'right' : 'left'; + + // 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`; + this.iframe.style[side] = `${spacing.side}px`; + } + + 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 === 'VUE_APP_READY') { + this.handleVueAppReady(); + } else if (event.data.type === 'CLOSE_WIDGET') { + this.hideChat(); + } else if (event.data.type === 'UPDATE_UNREAD_COUNT') { + this.updateUnreadCount(event.data.count); + } else if (event.data.type === 'EXPAND_WIDGET') { + this.expandWidget(); + } else if (event.data.type === 'COLLAPSE_WIDGET') { + this.collapseWidget(); + } + } + }); + } + + setupMobileDetection () { + window.addEventListener('resize', () => { + this.sendMobileState(); + if (this.isChatVisible) { + this.showChat(); + } + }); + window.addEventListener('orientationchange', () => { + this.sendMobileState(); + if (this.isChatVisible) { + this.showChat(); + } + }); + } + + handleVueAppReady () { + this.isVueAppReady = true; + // Show the widget button now that Vue app is ready + this.widgetButtonWrapper.style.display = ''; + + // Send JWT token if provided in config + if (this.config.libredesk_user_jwt) { + this.iframe.contentWindow.postMessage({ + type: 'SET_JWT_TOKEN', + jwt: this.config.libredesk_user_jwt + }, '*'); + } + } + + toggle () { + if (this.isChatVisible) { + this.hideChat(); + // Send WIDGET_CLOSED event to iframe + if (this.iframe && this.iframe.contentWindow) { + this.iframe.contentWindow.postMessage({ type: 'WIDGET_CLOSED' }, '*'); + } + } else { + this.showChat(); + // Send WIDGET_OPENED event to iframe + if (this.iframe && this.iframe.contentWindow) { + this.iframe.contentWindow.postMessage({ type: 'WIDGET_OPENED' }, '*'); + } + } + } + + showChat () { + if (this.iframe) { + 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.borderRadius = '12px'; + this.iframe.style.boxShadow = '0 5px 40px rgba(0,0,0,0.2)'; + this.iframe.style.top = ''; + this.iframe.style.left = ''; + this.widgetButtonWrapper.style.display = ''; + + // Apply expanded or normal height based on current state + if (this.isExpanded) { + this.iframe.style.width = '650px'; + this.iframe.style.height = 'calc(100vh - 110px)'; + this.iframe.style.bottom = '90px'; + } else { + this.iframe.style.height = '700px'; + this.setLauncherPosition(); + } + } + this.isChatVisible = true; + this.toggleButton.style.transform = 'scale(0.9)'; + this.unreadBadge.style.display = 'none'; + + // Switch to arrow icon + if (this.defaultIcon) this.defaultIcon.style.display = 'none'; + this.arrowIcon.style.display = 'flex'; + } + } + + hideChat () { + if (this.iframe) { + this.iframe.style.display = 'none'; + this.isChatVisible = false; + this.toggleButton.style.transform = 'scale(1)'; + this.widgetButtonWrapper.style.display = ''; + + // Switch back to default icon + if (this.defaultIcon) this.defaultIcon.style.display = 'block'; + this.arrowIcon.style.display = 'none'; + } + } + + 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'; + } + } + + expandWidget () { + if (this.iframe && this.isChatVisible && !this.isMobile) { + this.isExpanded = true; + + // Expand to nearly full viewport height with gaps and wider + this.iframe.style.width = '650px'; + this.iframe.style.height = 'calc(100vh - 110px)'; + this.iframe.style.bottom = '90px'; + this.iframe.style.maxHeight = ''; + + // Send expanded state to iframe + this.iframe.contentWindow.postMessage({ + type: 'WIDGET_EXPANDED', + isExpanded: true + }, '*'); + } + } + + collapseWidget () { + if (this.iframe && this.isChatVisible && !this.isMobile) { + this.isExpanded = false; + + // Reset to original size and position + this.iframe.style.width = '400px'; + this.iframe.style.height = '700px'; + this.iframe.style.maxHeight = ''; + this.iframe.style.top = ''; + + // Restore launcher position + this.setLauncherPosition(); + + // Send collapsed state to iframe + this.iframe.contentWindow.postMessage({ + type: 'WIDGET_EXPANDED', + isExpanded: false + }, '*'); + } + } + + destroy () { + 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); + this.iframe = null; + } + this.isChatVisible = false; + } + } + + // Global widget instance + window.LibredeskWidget = LibredeskWidget; + + // Auto-initialize if configuration is provided + if (window.LibredeskConfig) { + window.LibredeskWidget = new LibredeskWidget(window.LibredeskConfig); + } + + window.initLibredeskWidget = function (config = {}) { + if (window.LibredeskWidget && window.LibredeskWidget instanceof LibredeskWidget) { + console.warn('Libredesk Widget is already initialized'); + return window.LibredeskWidget; + } + window.LibredeskWidget = new LibredeskWidget(config); + return window.LibredeskWidget; + }; + + LibredeskWidget.show = function () { + if (window.LibredeskWidget && window.LibredeskWidget instanceof LibredeskWidget) { + window.LibredeskWidget.showChat(); + } else { + console.warn('Libredesk Widget is not initialized. Call initLibredeskWidget() first.'); + } + }; + + LibredeskWidget.hide = function () { + if (window.LibredeskWidget && window.LibredeskWidget instanceof LibredeskWidget) { + window.LibredeskWidget.hideChat(); + } else { + console.warn('Libredesk Widget is not initialized. Call initLibredeskWidget() first.'); + } + }; + + LibredeskWidget.toggle = function () { + if (window.LibredeskWidget && window.LibredeskWidget instanceof LibredeskWidget) { + window.LibredeskWidget.toggle(); + } else { + console.warn('Libredesk Widget is not initialized. Call initLibredeskWidget() first.'); + } + }; + + LibredeskWidget.isVisible = function () { + if (window.LibredeskWidget && window.LibredeskWidget instanceof LibredeskWidget) { + return window.LibredeskWidget.isChatVisible; + } else { + console.warn('Libredesk Widget is not initialized. Call initLibredeskWidget() first.'); + return false; + } + }; + +})(); \ No newline at end of file