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
+
+
+
+
+ {{ $t('admin.inbox.livechat.prechatForm.enabled.description') }}
+
+ {{ $t('admin.inbox.livechat.prechatForm.title.description') }}
+
+ {{ $t('admin.inbox.livechat.prechatForm.fields') }}
+
+
+
+ {{ $t('admin.inbox.livechat.prechatForm.availableFields') }}
+
+