diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index b62b5acf..4537a442 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -3,12 +3,22 @@ name: CI on: push: branches: [main] + paths-ignore: ['**.md', 'docs/**', 'LICENSE', '.github/ISSUE_TEMPLATE/**'] pull_request: branches: [main] + paths-ignore: ['**.md', 'docs/**', 'LICENSE', '.github/ISSUE_TEMPLATE/**'] + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: build-and-test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4, 5] services: db: image: postgres:17-alpine @@ -41,13 +51,22 @@ jobs: with: go-version: "1.25.0" + - name: Install pnpm + run: npm install -g pnpm@9.15.3 + - name: Set up Node.js uses: actions/setup-node@v3 with: node-version: "20" + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml - - name: Install pnpm - run: npm install -g pnpm@9.15.3 + - name: Cache the cypress binary + uses: actions/cache@v4 + with: + path: ~/.cache/Cypress + key: cypress-${{ runner.os }}-${{ hashFiles('frontend/pnpm-lock.yaml') }} + restore-keys: cypress-${{ runner.os }}- - name: Install cypress deps run: sudo apt-get update && sudo apt-get install -y libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb @@ -89,4 +108,6 @@ jobs: sleep 1 done cd frontend - pnpm run test:e2e:ci \ No newline at end of file + specs=$(find cypress/e2e -type f -regextype posix-extended -regex '.*\.(cy|spec)\.(js|jsx|ts|tsx)$' | sort | awk -v s=${{ matrix.shard }} -v n=5 'NR % n == s % n' | paste -sd,) + echo "shard ${{ matrix.shard }} runs: $specs" + pnpm exec cypress run --e2e --headless --spec "$specs" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 91b6950e..4c6b7a2e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ uploads dist/ .vscode/ static/widget.min.js + +.playwright-mcp/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59993f38..b20d6b97 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,7 @@ Thanks for your interest in contributing. - Bug fixes and documentation improvements can go straight to a PR, no prior discussion needed. - Anything larger should start with an issue. See below for why. - Keep changes focused on a single concern wherever possible. +- For translations, please contribute through [Crowdin](https://crowdin.com/project/libredesk) instead of opening PRs for translation changes. ## Pull Requests diff --git a/Makefile b/Makefile index 51a08865..675882b2 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,8 @@ BUILDSTR := ${VERSION} (\#${LAST_COMMIT} $(shell date -u +"%Y-%m-%dT%H:%M:%S%z") BIN := libredesk FRONTEND_DIR := frontend FRONTEND_DIST := ${FRONTEND_DIR}/dist -STATIC := ${FRONTEND_DIST} i18n schema.sql static +WIDGET_JS_MIN := static/widget.min.js +STATIC := ${FRONTEND_DIST} i18n schema.sql static/email-templates static/public ${WIDGET_JS_MIN}:static/widget.js GOPATH ?= $(HOME)/go STUFFBIN ?= $(GOPATH)/bin/stuffbin @@ -30,10 +31,12 @@ install-deps: $(STUFFBIN) # Build the frontend for production (both apps). .PHONY: frontend-build -frontend-build: install-deps - @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 +frontend-build: frontend-build-main frontend-build-widget + +.PHONY: minify-widget-js +minify-widget-js: install-deps + @echo "→ Minifying widget loader..." + @cd ${FRONTEND_DIR} && pnpm build:widget-loader # Build only the main frontend app. .PHONY: frontend-build-main @@ -81,18 +84,19 @@ run-frontend-widget: .PHONY: build-backend build-backend: $(STUFFBIN) @echo "→ Building backend..." - @CGO_ENABLED=0 go build -a \ + @CGO_ENABLED=0 go build \ -ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -s -w" \ -o ${BIN} cmd/*.go # Main build target: builds both frontend and backend, then stuffs static assets into the binary. .PHONY: build -build: frontend-build build-backend stuff +build: frontend-build build-backend + @$(MAKE) --no-print-directory stuff @echo "→ Build successful. Current version: $(VERSION)" # Stuff static assets into the binary using stuffbin. .PHONY: stuff -stuff: $(STUFFBIN) +stuff: $(STUFFBIN) minify-widget-js @echo "→ Stuffing static assets into binary..." @$(STUFFBIN) -a stuff -in ${BIN} -out ${BIN} ${STATIC} diff --git a/README.md b/README.md index 491eee88..6ec27766 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@
Modern, open source, self-hosted omnichannel customer support desk. Live chat, email, and more in a single binary. -![image](https://libredesk.io/hero-dark.png?q=5) +![image](https://libredesk.io/hero-dark.png?v=1) Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**live demo**](https://demo.libredesk.io/). diff --git a/ROADMAP.md b/ROADMAP.md index 210fcff5..ad5a1f81 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,9 +4,10 @@ The open-source customer support platform you actually own. One inbox for email, ## Near Term - WhatsApp channel - WIP +- Web / Customer Portal to manage tickets - WIP +- 2FA for password login - WIP +- Telegram channel - TODO ## Mid Term -- Portal to manage tickets - TODO -- Conversation reports - TODO +- Extensive conversation reports - TODO - Contact merging - TODO -- 2FA for password login - TODO diff --git a/cmd/ai.go b/cmd/ai.go index e2c0bfab..ca7e7ddf 100644 --- a/cmd/ai.go +++ b/cmd/ai.go @@ -45,6 +45,11 @@ type suggestTagsReq struct { ConversationUUID string `json:"conversation_uuid"` } +type promptReq struct { + Title string `json:"title"` + Content string `json:"content"` +} + type snippetReq struct { Title string `json:"title"` Content string `json:"content"` @@ -55,6 +60,11 @@ type snippetImportReq struct { URL string `json:"url"` } +type aiToolReq struct { + aimodels.Tool + RequiresAgentApproval *bool `json:"requires_agent_approval"` +} + // handleAICompletion runs a stored prompt over the supplied content (reply-box actions). func handleAICompletion(r *fastglue.Request) error { var ( @@ -81,6 +91,65 @@ func handleGetAIPrompts(r *fastglue.Request) error { return r.SendEnvelope(resp) } +func handleGetAIPrompt(r *fastglue.Request) error { + app := r.Context.(*App) + id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) + } + p, err := app.ai.GetPromptByID(id) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(p) +} + +func handleCreateAIPrompt(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + req promptReq + ) + if err := r.Decode(&req, "json"); err != nil { + return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil)) + } + p, err := app.ai.CreatePrompt(req.Title, req.Content) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(p) +} + +func handleUpdateAIPrompt(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + req promptReq + ) + id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) + } + if err := r.Decode(&req, "json"); err != nil { + return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil)) + } + p, err := app.ai.UpdatePrompt(id, req.Title, req.Content) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(p) +} + +func handleDeleteAIPrompt(r *fastglue.Request) error { + app := r.Context.(*App) + id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) + } + if err := app.ai.DeletePrompt(id); err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(true) +} + // handleGetAIConfig returns the sanitized provider config for a type (completion/embedding). func handleGetAIConfig(r *fastglue.Request) error { app := r.Context.(*App) @@ -150,12 +219,14 @@ func handleGetAITool(r *fastglue.Request) error { func handleCreateAITool(r *fastglue.Request) error { var ( app = r.Context.(*App) - req aimodels.Tool + req aiToolReq ) if err := r.Decode(&req, "json"); err != nil { return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil)) } - tool, err := app.ai.CreateTool(req) + toolInput := req.Tool + toolInput.RequiresAgentApproval = req.RequiresAgentApproval == nil || *req.RequiresAgentApproval + tool, err := app.ai.CreateTool(toolInput) if err != nil { return sendErrorEnvelope(r, err) } @@ -165,7 +236,7 @@ func handleCreateAITool(r *fastglue.Request) error { func handleUpdateAITool(r *fastglue.Request) error { var ( app = r.Context.(*App) - req aimodels.Tool + req aiToolReq ) id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) if err != nil { @@ -174,7 +245,17 @@ func handleUpdateAITool(r *fastglue.Request) error { if err := r.Decode(&req, "json"); err != nil { return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil)) } - tool, err := app.ai.UpdateTool(id, req) + toolInput := req.Tool + if req.RequiresAgentApproval == nil { + existing, err := app.ai.GetTool(id) + if err != nil { + return sendErrorEnvelope(r, err) + } + toolInput.RequiresAgentApproval = existing.RequiresAgentApproval + } else { + toolInput.RequiresAgentApproval = *req.RequiresAgentApproval + } + tool, err := app.ai.UpdateTool(id, toolInput) if err != nil { return sendErrorEnvelope(r, err) } @@ -288,14 +369,30 @@ func handleAIGenerateReply(r *fastglue.Request) error { } transcript = conversationTranscript(app, req.ConversationUUID) } - resp, err := app.ai.GenerateReply(r.RequestCtx, transcript, req.Instruction, ai.ToolContext{}, generateReplyTools(app, user, conv)) + toolIDs := []int(nil) + tctx := ai.ToolContext{} + scope := ai.AgentRunScope{AgentID: auser.ID, Surface: aimodels.ToolInvocationReply} + if conv != nil { + toolIDs, err = app.ai.GetEnabledGenerateReplyToolIDs() + if err != nil { + return sendErrorEnvelope(r, err) + } + tctx = agentToolContext(conv) + scope.ConversationID = conv.ID + scope.ConversationUUID = conv.UUID + app.ai.ClearPendingAgentRuns(scope) + } + resp, err := app.ai.GenerateReply(r.RequestCtx, transcript, req.Instruction, tctx, generateReplyTools(app, user, conv), toolIDs, scope) if err != nil { return sendErrorEnvelope(r, err) } - if strings.TrimSpace(resp) == "" { + if resp.Status == aimodels.AgentRunCompleted && strings.TrimSpace(resp.Content) == "" { return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil)) } - return r.SendEnvelope(stringutil.Markdown2HTML(resp)) + if resp.Status == aimodels.AgentRunCompleted { + resp.Content = stringutil.Markdown2HTML(resp.Content) + } + return r.SendEnvelope(resp) } // handleAISummarizeConversation summarizes a conversation and posts the summary as the requesting agent's private note. @@ -402,22 +499,46 @@ func handleAICopilot(r *fastglue.Request) error { } history = append(history, aimodels.ChatMessage{Role: aimodels.RoleUser, Content: req.Message}) - convoContext := conversationTranscript(app, req.ConversationUUID) - resp, err := app.ai.Copilot(r.RequestCtx, convoContext, history, ai.ToolContext{}, copilotTools(app, user, conv), persona) + scope := ai.AgentRunScope{AgentID: auser.ID, ConversationID: conv.ID, ConversationUUID: conv.UUID, Surface: aimodels.ToolInvocationCopilot, UserMessage: req.Message} + app.ai.ClearPendingAgentRuns(scope) + toolIDs, err := app.ai.GetEnabledCopilotToolIDs() if err != nil { return sendErrorEnvelope(r, err) } - if strings.TrimSpace(resp) == "" { - return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil)) - } - // Persist the exchange only after a successful reply, so a failed or empty call leaves no orphaned turn. - if err := app.ai.SaveCopilotMessage(conv.ID, auser.ID, aimodels.RoleUser, req.Message); err != nil { + convoContext := conversationTranscript(app, req.ConversationUUID) + resp, err := app.ai.Copilot(r.RequestCtx, convoContext, history, agentToolContext(conv), copilotTools(app, user, conv), persona, toolIDs, scope) + if err != nil { return sendErrorEnvelope(r, err) } - if err := app.ai.SaveCopilotMessage(conv.ID, auser.ID, aimodels.RoleAssistant, resp); err != nil { - app.lo.Error("error saving copilot reply", "error", err) + if resp.Status == aimodels.AgentRunCompleted && strings.TrimSpace(resp.Content) == "" { + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil)) } - return r.SendEnvelope(stringutil.Markdown2HTML(resp)) + if resp.Status == aimodels.AgentRunCompleted { + if err := app.ai.SaveCopilotMessage(conv.ID, auser.ID, aimodels.RoleUser, req.Message); err != nil { + return sendErrorEnvelope(r, err) + } + if err := app.ai.SaveCopilotMessage(conv.ID, auser.ID, aimodels.RoleAssistant, resp.Content); err != nil { + app.lo.Error("error saving copilot reply", "error", err) + } + resp.Content = stringutil.Markdown2HTML(resp.Content) + } + return r.SendEnvelope(resp) +} + +func handleApproveAIToolRun(r *fastglue.Request) error { + resp, err := decideAIToolRun(r, true) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(resp) +} + +func handleDeclineAIToolRun(r *fastglue.Request) error { + resp, err := decideAIToolRun(r, false) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(resp) } // handleGetCopilotMessages returns the requesting agent's persisted copilot chat for a conversation. @@ -442,6 +563,13 @@ func handleGetCopilotMessages(r *fastglue.Request) error { msgs[i].Content = stringutil.Markdown2HTML(msgs[i].Content) } } + scope := ai.AgentRunScope{AgentID: auser.ID, ConversationID: conv.ID, ConversationUUID: conv.UUID, Surface: aimodels.ToolInvocationCopilot} + if userMessage, approval := app.ai.PendingAgentApproval(scope); approval != nil { + if userMessage != "" { + msgs = append(msgs, aimodels.CopilotMessage{Role: aimodels.RoleUser, Content: userMessage}) + } + msgs = append(msgs, aimodels.CopilotMessage{Role: aimodels.RoleApproval, Approval: approval}) + } return r.SendEnvelope(msgs) } @@ -460,6 +588,7 @@ func handleClearCopilotMessages(r *fastglue.Request) error { if err := app.ai.ClearCopilotMessages(conv.ID, auser.ID); err != nil { return sendErrorEnvelope(r, err) } + app.ai.ClearPendingAgentRuns(ai.AgentRunScope{AgentID: auser.ID, ConversationID: conv.ID, ConversationUUID: conv.UUID, Surface: aimodels.ToolInvocationCopilot}) return r.SendEnvelope(true) } @@ -484,3 +613,54 @@ func conversationTranscript(app *App, uuid string) string { } return cmodels.Transcript(msgs, maxTranscriptMessages) } + +func decideAIToolRun(r *fastglue.Request, approved bool) (aimodels.AgentRunResult, error) { + app := r.Context.(*App) + auser := r.RequestCtx.UserValue("user").(amodels.User) + runID := r.RequestCtx.UserValue("id").(string) + scope, err := app.ai.PendingAgentRunScope(runID, auser.ID) + if err != nil { + return aimodels.AgentRunResult{}, err + } + if scope.ConversationUUID != "" { + conv, err := enforceAIConversationAccess(r, scope.ConversationUUID) + if err != nil { + return aimodels.AgentRunResult{}, err + } + if conv.ID != scope.ConversationID { + return aimodels.AgentRunResult{}, envelope.NewError(envelope.PermissionError, app.i18n.T("ai.toolApprovalUnavailable"), nil) + } + } + resp, resumedScope, err := app.ai.ResumeAgentRun(r.RequestCtx, runID, auser.ID, approved) + if err != nil { + return aimodels.AgentRunResult{}, err + } + if resp.Status == aimodels.AgentRunCompleted && strings.TrimSpace(resp.Content) == "" { + return aimodels.AgentRunResult{}, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil) + } + if resp.Status == aimodels.AgentRunCompleted { + if resumedScope.Surface == aimodels.ToolInvocationCopilot { + if err := app.ai.SaveCopilotMessage(resumedScope.ConversationID, auser.ID, aimodels.RoleUser, resumedScope.UserMessage); err != nil { + app.lo.Error("error saving copilot message", "error", err) + } else if err := app.ai.SaveCopilotMessage(resumedScope.ConversationID, auser.ID, aimodels.RoleAssistant, resp.Content); err != nil { + app.lo.Error("error saving copilot reply", "error", err) + } + } + resp.Content = stringutil.Markdown2HTML(resp.Content) + } + return resp, nil +} + +func agentToolContext(conv *cmodels.Conversation) ai.ToolContext { + return ai.ToolContext{ + ContactID: conv.Contact.ID, + ContactExternalID: conv.Contact.ExternalUserID.String, + ContactType: conv.Contact.Type, + ConversationUUID: conv.UUID, + InboxID: conv.InboxID, + ContactEmail: func() string { return conv.Contact.Email.String }, + // The agent picked this conversation and reviews the call before it runs, so the customer's + // own OTP state does not gate an agent-initiated lookup. + Verified: func() bool { return true }, + } +} diff --git a/cmd/aitools.go b/cmd/aitools.go index 0a296e88..ba0d4215 100644 --- a/cmd/aitools.go +++ b/cmd/aitools.go @@ -232,7 +232,7 @@ func (t *searchContactsTool) Execute(ctx context.Context, args string) (string, if !allowed { return "You do not have permission to search contacts.", nil } - results, err := t.app.search.Contacts(email) + results, err := t.app.search.Contacts(email, maxContactSearchLimit) if err != nil { return "", err } diff --git a/cmd/chat.go b/cmd/chat.go index 113a0dd0..18003e2f 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -1,6 +1,7 @@ package main import ( + "cmp" "context" "crypto/rand" "encoding/base64" @@ -21,6 +22,7 @@ import ( cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat/proactive" imodels "github.com/abhinavxd/libredesk/internal/inbox/models" "github.com/abhinavxd/libredesk/internal/stringutil" umodels "github.com/abhinavxd/libredesk/internal/user/models" @@ -82,11 +84,20 @@ type customAttributeWidget struct { } type chatInitReq struct { - Message string `json:"message"` + DeliveryID string `json:"delivery_id"` + BrowserKey string `json:"browser_key"` + Message string `json:"message"` + FormData map[string]any `json:"form_data"` +} + +type handoffFormReq struct { FormData map[string]any `json:"form_data"` } type chatSettingsResponse struct { + Campaigns *struct{} `json:"campaigns,omitempty"` + HasCampaigns bool `json:"has_campaigns"` + CampaignDelaySeconds int `json:"campaign_delay_seconds"` livechat.Config // Hide server-side fields from the public widget response. TrustedDomains *struct{} `json:"trusted_domains,omitempty"` @@ -114,10 +125,7 @@ func handleGetChatLauncherSettings(r *fastglue.Request) error { return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, err.Error(), nil)) } - return r.SendEnvelope(map[string]any{ - "launcher": config.Launcher, - "colors": config.Colors, - }) + return r.SendEnvelope(chatLauncherSettings(config)) } // handleGetChatSettings returns the live chat settings for the widget @@ -128,9 +136,16 @@ func handleGetChatSettings(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, err.Error(), nil)) } + customAttributes := map[int]customAttributeWidget(nil) + if config.PreChatForm.Enabled { + config, customAttributes = filterPreChatForms(config, app) + } response := chatSettingsResponse{ - Config: config, + Config: config, + HasCampaigns: slices.ContainsFunc(config.Campaigns, func(c proactive.Campaign) bool { return c.Enabled }), + CampaignDelaySeconds: earliestCampaignDelay(config.Campaigns), + CustomAttributes: customAttributes, } // Get business hours data if office hours feature is enabled. @@ -163,15 +178,6 @@ func handleGetChatSettings(r *fastglue.Request) error { } } - // 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) } @@ -211,6 +217,11 @@ func handleChatInit(r *fastglue.Request) error { if err != nil { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } + initialConfig := resolveInitialChatConfig(config, getWidgetIsVisitor(r)) + + if req.DeliveryID != "" { + return handleWidgetCampaignReply(r, req, inbox, initialConfig) + } // Check if user is already authenticated (has session token). contactID, _ = getWidgetContactID(r) @@ -219,11 +230,11 @@ func handleChatInit(r *fastglue.Request) error { // Custom attributes from JWT were already saved during /auth/exchange. // Only process form-level attributes here. isVisitor = getWidgetIsVisitor(r) - conversationAttrs = saveContactAttrsAndCollectConvoAttrs(app, contactID, nil, req.FormData, config) + conversationAttrs = saveContactAttrsAndCollectConvoAttrs(app, contactID, nil, req.FormData, initialConfig) } else { // New visitor - create visitor and session token. isVisitor = true - visitor, newSessionToken, conversationAttrs, err = createVisitorContact(app, req.FormData, config, inbox) + visitor, newSessionToken, conversationAttrs, err = createVisitorContact(app, req.FormData, initialConfig, inbox) if err != nil { return sendErrorEnvelope(r, err) } @@ -283,7 +294,7 @@ func handleChatInit(r *fastglue.Request) error { } // Process post-message hooks for the new conversation and initial message. - if err := app.conversation.ProcessIncomingMessageHooks(conversationUUID, true); err != nil { + if err := app.conversation.ProcessIncomingMessageHooks(message, true); err != nil { app.lo.Error("error processing incoming message hooks for initial message", "conversation_uuid", conversationUUID, "error", err) } @@ -510,6 +521,18 @@ func handleGetConversations(r *fastglue.Request) error { app.lo.Error("error fetching conversations for contact", "contact_id", contactID, "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } + unreadMessages, err := app.conversation.GetContactUnreadPreviewMessages(contactID, inbox.ID, 3) + if err != nil { + return sendErrorEnvelope(r, err) + } + for _, message := range unreadMessages { + for i := range chatConversations { + if chatConversations[i].UUID == message.ConversationUUID { + chatConversations[i].UnreadMessages = append(chatConversations[i].UnreadMessages, message) + break + } + } + } return r.SendEnvelope(chatConversations) } @@ -566,6 +589,70 @@ func handleChatSendMessage(r *fastglue.Request) error { return sendChatMessageResponse(app, r, message.UUID) } +// handleChatSubmitHandoffForm saves the form the AI agent asks the visitor to fill before it hands the chat to a human agent. +func handleChatSubmitHandoffForm(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + conversationUUID = r.RequestCtx.UserValue("uuid").(string) + req handoffFormReq + ) + if err := r.Decode(&req, "json"); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError) + } + contactID, conversation, err := getContactConversation(r, conversationUUID) + if err != nil { + return sendErrorEnvelope(r, err) + } + config, err := getWidgetConfig(r) + if err != nil { + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) + } + config = config.ResolvePreChatForm(getWidgetIsVisitor(r)) + if !config.PreChatForm.Enabled || !config.PreChatForm.HandoffOnly { + return r.SendErrorEnvelope(fasthttp.StatusConflict, app.i18n.T("ai.agent.handoffFormUnavailable"), nil, envelope.ConflictError) + } + config.PreChatForm.Fields, _ = filterPreChatFormFields(config.PreChatForm.Fields, app) + if err := validateRequiredFormFields(req.FormData, config, app); err != nil { + return sendErrorEnvelope(r, err) + } + contact, err := app.user.GetContactOrVisitor(contactID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + // An identified contact's details come from the JWT, which resyncs them on every exchange, so the form only fills blanks. + var identified *umodels.User + if !getWidgetIsVisitor(r) { + identified = &contact + } + name, email, phone, countryCode, err := validateFormData(app, req.FormData, config, identified) + if err != nil { + return sendErrorEnvelope(r, err) + } + name = cmp.Or(name, contact.FirstName) + email = cmp.Or(email, contact.Email.String) + phone = cmp.Or(phone, contact.PhoneNumber.String) + countryCode = cmp.Or(countryCode, contact.PhoneNumberCountryCode.String) + if name != "" || email != "" || phone != "" { + if err := app.user.UpdateContactBasicInfo(contactID, name, contact.LastName, email, phone, countryCode); err != nil { + return sendErrorEnvelope(r, err) + } + } + conversationAttrs := saveContactAttrsAndCollectConvoAttrs(app, contactID, nil, req.FormData, config) + if len(conversationAttrs) > 0 { + merged := make(map[string]any) + _ = json.Unmarshal(conversation.CustomAttributes, &merged) + maps.Copy(merged, conversationAttrs) + if err := app.conversation.UpdateConversationCustomAttributes(conversation.UUID, merged); err != nil { + return sendErrorEnvelope(r, err) + } + } + if err := app.aiAgent.CompleteHandoff(conversation); err != nil { + app.lo.Error("error completing AI handoff form", "conversation_uuid", conversation.UUID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusConflict, app.i18n.T("ai.agent.handoffFormUnavailable"), nil, envelope.ConflictError) + } + return r.SendEnvelope(true) +} + // handleWidgetMediaUpload handles media uploads for the widget. func handleWidgetMediaUpload(r *fastglue.Request) error { app := r.Context.(*App) @@ -694,6 +781,7 @@ func sendChatMessageResponse(app *App, r *fastglue.Request, messageUUID string) author.Email = null.String{} return r.SendEnvelope(cmodels.ChatMessage{ + ID: message.ID, UUID: message.UUID, CreatedAt: message.CreatedAt, Content: message.Content, @@ -1211,6 +1299,19 @@ func validateFormData(app *App, formData map[string]any, config livechat.Config, return finalName, finalEmail, finalPhone, finalPhoneCountryCode, nil } +func validateRequiredFormFields(formData map[string]any, config livechat.Config, app *App) error { + for _, field := range config.PreChatForm.Fields { + if !field.Enabled || !field.Required || field.Type == "checkbox" { + continue + } + value, exists := formData[field.Key] + if !exists || !isFormFieldValuePresent(field, value) { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", field.Label), nil) + } + } + return nil +} + func resolveFormField(formData map[string]any, key, existing string) string { if existing != "" { return existing @@ -1221,6 +1322,28 @@ func resolveFormField(formData map[string]any, key, existing string) string { return "" } +func isFormFieldValuePresent(field livechat.PreChatFormField, value any) bool { + switch field.Type { + case "checkbox": + _, ok := value.(bool) + return ok + case "number": + number, ok := value.(float64) + return ok && !math.IsNaN(number) && !math.IsInf(number, 0) + default: + text, ok := value.(string) + return ok && strings.TrimSpace(text) != "" + } +} + +func resolveInitialChatConfig(config livechat.Config, isVisitor bool) livechat.Config { + config = config.ResolvePreChatForm(isVisitor) + if config.PreChatForm.HandoffOnly { + config.PreChatForm.Enabled = false + } + return config +} + // 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 { @@ -1276,6 +1399,27 @@ func filterPreChatFormFields(fields []livechat.PreChatFormField, app *App) ([]li return filteredFields, existingCustomAttrs } +func filterPreChatForms(config livechat.Config, app *App) (livechat.Config, map[int]customAttributeWidget) { + customAttributes := make(map[int]customAttributeWidget) + filter := func(fields []livechat.PreChatFormField) []livechat.PreChatFormField { + filtered, attributes := filterPreChatFormFields(fields, app) + maps.Copy(customAttributes, attributes) + return filtered + } + + config.PreChatForm.Fields = filter(config.PreChatForm.Fields) + if config.PreChatForm.Visitors != nil && config.PreChatForm.Visitors.Fields != nil { + config.PreChatForm.Visitors.Fields = filter(config.PreChatForm.Visitors.Fields) + } + if config.PreChatForm.Users != nil && config.PreChatForm.Users.Fields != nil { + config.PreChatForm.Users.Fields = filter(config.PreChatForm.Users.Fields) + } + if len(customAttributes) == 0 { + return config, nil + } + return config, customAttributes +} + // getSessionDuration returns the configured session TTL for authenticated users. // Falls back to defaultSessionTTL if the config value is empty or invalid. // Enforces a minimum of 1 hour. @@ -1309,3 +1453,21 @@ func canReply(r *fastglue.Request, conversation cmodels.Conversation) error { } return nil } + +func launcherBranding(b livechat.Branding) map[string]any { + return map[string]any{ + "colors": b.Colors, + "launcher": b.Launcher, + } +} + +func chatLauncherSettings(config livechat.Config) map[string]any { + return map[string]any{ + "theme": config.Theme, + "launcher": config.Launcher, + "branding": map[string]any{ + "light": launcherBranding(config.Branding.Light), + "dark": launcherBranding(config.Branding.Dark), + }, + } +} diff --git a/cmd/chat_test.go b/cmd/chat_test.go new file mode 100644 index 00000000..7bcb1f81 --- /dev/null +++ b/cmd/chat_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "math" + "testing" + + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" +) + +func TestIsFormFieldValuePresent(t *testing.T) { + tests := []struct { + name string + field livechat.PreChatFormField + value any + want bool + }{ + {name: "text", field: livechat.PreChatFormField{Type: "text"}, value: "answer", want: true}, + {name: "blank text", field: livechat.PreChatFormField{Type: "text"}, value: " "}, + {name: "wrong text type", field: livechat.PreChatFormField{Type: "text"}, value: []string{"answer"}}, + {name: "number", field: livechat.PreChatFormField{Type: "number"}, value: float64(0), want: true}, + {name: "invalid number", field: livechat.PreChatFormField{Type: "number"}, value: math.NaN()}, + {name: "checkbox", field: livechat.PreChatFormField{Type: "checkbox"}, value: false, want: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := isFormFieldValuePresent(tc.field, tc.value); got != tc.want { + t.Fatalf("got %v, want %v", got, tc.want) + } + }) + } +} + +func TestInitialChatConfigDisablesHandoffOnlyForm(t *testing.T) { + var config livechat.Config + config.PreChatForm.Enabled = true + config.PreChatForm.HandoffOnly = true + + initial := resolveInitialChatConfig(config, true) + if initial.PreChatForm.Enabled { + t.Fatal("handoff-only form remained enabled for initial chat") + } + if !config.PreChatForm.Enabled { + t.Fatal("input config was mutated") + } +} + +func TestInitialChatConfigUsesAudienceSetting(t *testing.T) { + var config livechat.Config + config.PreChatForm.Enabled = true + showVisitors := true + showUsers := false + config.PreChatForm.Visitors = &livechat.AudiencePreChatFormConfig{Enabled: &showVisitors} + config.PreChatForm.Users = &livechat.AudiencePreChatFormConfig{Enabled: &showUsers} + + if !resolveInitialChatConfig(config, true).PreChatForm.Enabled { + t.Fatal("visitor pre-chat form was disabled") + } + if resolveInitialChatConfig(config, false).PreChatForm.Enabled { + t.Fatal("user pre-chat form remained enabled") + } +} + +func TestValidateHandoffFormUsesSubmittedContactDetails(t *testing.T) { + var config livechat.Config + config.PreChatForm.Enabled = true + config.PreChatForm.Fields = []livechat.PreChatFormField{{ + Key: "name", + Type: "text", + Enabled: true, + Required: true, + IsDefault: true, + }} + + name, _, _, _, err := validateFormData(nil, map[string]any{"name": "QA Visitor"}, config, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if name != "QA Visitor" { + t.Fatalf("got name %q", name) + } +} diff --git a/cmd/config.go b/cmd/config.go index 8a04a219..788bd6ba 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -4,9 +4,12 @@ import ( "encoding/json" "github.com/abhinavxd/libredesk/internal/envelope" + oidcmodels "github.com/abhinavxd/libredesk/internal/oidc/models" "github.com/zerodha/fastglue" ) +const localLoginEnabledKey = "auth.local_login_enabled" + // handleGetConfig returns the public configuration needed for app initialization, this includes minimal app settings and enabled SSO providers (without secrets). func handleGetConfig(r *fastglue.Request) error { var app = r.Context.(*App) @@ -58,6 +61,23 @@ func handleGetConfig(r *fastglue.Request) error { // Add SSO providers to the response publicSettings["app.sso_providers"] = enabledProviders + publicSettings["auth.local_login_enabled"] = isLocalLoginEnabled(oidcProviders) return r.SendEnvelope(publicSettings) } + +func localLoginDisabledInConfig() bool { + return ko.Exists(localLoginEnabledKey) && !ko.Bool(localLoginEnabledKey) +} + +func isLocalLoginEnabled(providers []oidcmodels.OIDC) bool { + if !localLoginDisabledInConfig() { + return true + } + for _, provider := range providers { + if provider.Enabled { + return false + } + } + return true +} diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 00000000..fcca43fa --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "testing" + + oidcmodels "github.com/abhinavxd/libredesk/internal/oidc/models" +) + +func TestIsLocalLoginEnabled(t *testing.T) { + tests := []struct { + name string + configured *bool + providers []oidcmodels.OIDC + want bool + }{ + {name: "defaults to enabled", want: true}, + {name: "remains enabled when configured", configured: boolPointer(true), want: true}, + {name: "remains enabled without an enabled OIDC provider", configured: boolPointer(false), providers: []oidcmodels.OIDC{{Enabled: false}}, want: true}, + {name: "is disabled with an enabled OIDC provider", configured: boolPointer(false), providers: []oidcmodels.OIDC{{Enabled: true}}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ko.Delete(localLoginEnabledKey) + if tt.configured != nil { + if err := ko.Set(localLoginEnabledKey, *tt.configured); err != nil { + t.Fatalf("set config: %v", err) + } + } + t.Cleanup(func() { ko.Delete(localLoginEnabledKey) }) + + if got := isLocalLoginEnabled(tt.providers); got != tt.want { + t.Fatalf("isLocalLoginEnabled() = %v, want %v", got, tt.want) + } + }) + } +} + +func boolPointer(value bool) *bool { + return &value +} diff --git a/cmd/contacts.go b/cmd/contacts.go index 0fcfbdc4..6eac37cc 100644 --- a/cmd/contacts.go +++ b/cmd/contacts.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "mime/multipart" "path/filepath" "strconv" "strings" @@ -24,6 +25,23 @@ type blockContactReq struct { Enabled bool `json:"enabled"` } +func handleCreateContact(r *fastglue.Request) error { + var app = r.Context.(*App) + + contact, _, err := contactFromForm(r) + if err != nil { + return sendErrorEnvelope(r, err) + } + if err := app.user.CreateContact(&contact); err != nil { + return sendErrorEnvelope(r, err) + } + created, err := app.user.GetContactOrVisitor(contact.ID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(created) +} + // handleGetContacts returns a list of contacts from the database. func handleGetContacts(r *fastglue.Request) error { var ( @@ -81,75 +99,9 @@ func handleUpdateContact(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - form, err := r.RequestCtx.MultipartForm() + contactToUpdate, form, err := contactFromForm(r) if err != nil { - app.lo.Error("error parsing form data", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.GeneralError) - } - - // Parse form data - firstName := "" - if v, ok := form.Value["first_name"]; ok && len(v) > 0 { - firstName = string(v[0]) - } - lastName := "" - if v, ok := form.Value["last_name"]; ok && len(v) > 0 { - lastName = string(v[0]) - } - email := "" - if v, ok := form.Value["email"]; ok && len(v) > 0 { - email = strings.TrimSpace(string(v[0])) - } - phoneNumber := "" - if v, ok := form.Value["phone_number"]; ok && len(v) > 0 { - phoneNumber = string(v[0]) - } - phoneNumberCountryCode := "" - if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 { - phoneNumberCountryCode = string(v[0]) - } - country := "" - if v, ok := form.Value["country"]; ok && len(v) > 0 { - country = string(v[0]) - } - avatarURL := "" - if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 { - avatarURL = string(v[0]) - } - - // Set nulls to empty strings. - if avatarURL == "null" { - avatarURL = "" - } - if phoneNumberCountryCode == "null" { - phoneNumberCountryCode = "" - } - if phoneNumber == "null" { - phoneNumber = "" - } - if country == "null" { - country = "" - } - - // Validate mandatory fields. - if email == "" { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "email"), nil, envelope.InputError) - } - if !stringutil.ValidEmail(email) { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("validation.invalidEmail"), nil, envelope.InputError) - } - if firstName == "" { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "first_name"), nil, envelope.InputError) - } - - contactToUpdate := models.User{ - FirstName: firstName, - LastName: lastName, - Email: null.StringFrom(email), - AvatarURL: null.NewString(avatarURL, avatarURL != ""), - PhoneNumber: null.NewString(phoneNumber, phoneNumber != ""), - PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""), - Country: null.NewString(country, country != ""), + return sendErrorEnvelope(r, err) } if err := app.user.UpdateContact(id, contactToUpdate); err != nil { @@ -157,7 +109,7 @@ func handleUpdateContact(r *fastglue.Request) error { } // Delete avatar? - if avatarURL == "" && contact.AvatarURL.Valid { + if !contactToUpdate.AvatarURL.Valid && contact.AvatarURL.Valid { fileName := filepath.Base(contact.AvatarURL.String) app.media.Delete(fileName) contact.AvatarURL.Valid = false @@ -165,8 +117,7 @@ func handleUpdateContact(r *fastglue.Request) error { } // Upload avatar? - files, ok := form.File["files"] - if ok && len(files) > 0 { + if files := form.File["files"]; len(files) > 0 { if err := uploadUserAvatar(r, contact, files); err != nil { return sendErrorEnvelope(r, err) } @@ -363,3 +314,51 @@ func handleBlockContact(r *fastglue.Request) error { } return r.SendEnvelope(contact) } + +func contactFromForm(r *fastglue.Request) (models.User, *multipart.Form, error) { + var app = r.Context.(*App) + + form, err := r.RequestCtx.MultipartForm() + if err != nil { + app.lo.Error("error parsing form data", "error", err) + return models.User{}, nil, envelope.NewError(envelope.GeneralError, app.i18n.T("errors.parsingRequest"), nil) + } + + value := func(key string) string { + v := "" + if vals := form.Value[key]; len(vals) > 0 { + v = strings.TrimSpace(vals[0]) + } + // The edit page sends cleared fields as the string "null". + if v == "null" { + return "" + } + return v + } + optional := func(key string) null.String { + v := value(key) + return null.NewString(v, v != "") + } + + email := value("email") + if email == "" { + return models.User{}, nil, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "email"), nil) + } + if !stringutil.ValidEmail(email) { + return models.User{}, nil, envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidEmail"), nil) + } + firstName := value("first_name") + if firstName == "" { + return models.User{}, nil, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "first_name"), nil) + } + + return models.User{ + FirstName: firstName, + LastName: value("last_name"), + Email: null.StringFrom(email), + AvatarURL: optional("avatar_url"), + PhoneNumber: optional("phone_number"), + PhoneNumberCountryCode: optional("phone_number_country_code"), + Country: optional("country"), + }, form, nil +} diff --git a/cmd/conversation.go b/cmd/conversation.go index 6e93524e..bc4de42a 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -780,7 +780,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } - if err = app.conversation.RemoveConversationAssignee(uuid, "user", user); err != nil { + if err = app.conversation.UnassignConversationUser(uuid, user); err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(true) diff --git a/cmd/csat.go b/cmd/csat.go index 5efe3fab..707bcc49 100644 --- a/cmd/csat.go +++ b/cmd/csat.go @@ -86,9 +86,15 @@ func handleUpdateCSATResponse(r *fastglue.Request) error { } if err := app.csat.UpdateResponse(uuid, rating, feedback, metaJSON); err != nil { + // Surface only known, user-safe messages; never leak raw driver errors + // (e.g. a malformed UUID triggers a Postgres syntax error string). + msg := app.i18n.T("globals.messages.somethingWentWrong") + if e, ok := err.(envelope.Error); ok { + msg = e.Error() + } return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ "Data": map[string]interface{}{ - "ErrorMessage": err.Error(), + "ErrorMessage": msg, }, }) } @@ -152,10 +158,7 @@ func handleSubmitCSATResponse(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid UUID", nil, envelope.InputError) } - // Trim feedback if it exceeds max length. - if len(req.Feedback) > maxCsatFeedbackLength { - req.Feedback = req.Feedback[:maxCsatFeedbackLength] - } + req.Feedback = truncateFeedback(req.Feedback) // Update CSAT response if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback, nil); err != nil { @@ -187,9 +190,7 @@ func validateCSATForm(r *fastglue.Request) (int, string, json.RawMessage, string return 0, "", nil, "csat.pleaseFillRequired" } - if len(feedback) > maxCsatFeedbackLength { - feedback = feedback[:maxCsatFeedbackLength] - } + feedback = truncateFeedback(feedback) // Collect extra form fields into meta, skipping the known fields. meta := make(map[string]string) @@ -220,3 +221,14 @@ func validateCSATForm(r *fastglue.Request) (int, string, json.RawMessage, string return rating, feedback, metaJSON, "" } + +func truncateFeedback(s string) string { + if len(s) <= maxCsatFeedbackLength { + return s + } + r := []rune(s) + if len(r) <= maxCsatFeedbackLength { + return s + } + return string(r[:maxCsatFeedbackLength]) +} diff --git a/cmd/custom_attributes.go b/cmd/custom_attributes.go index ee36bb6d..c30f5f24 100644 --- a/cmd/custom_attributes.go +++ b/cmd/custom_attributes.go @@ -1,11 +1,13 @@ package main import ( + "encoding/json" "slices" "strconv" cmodels "github.com/abhinavxd/libredesk/internal/custom_attribute/models" "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" ) @@ -109,6 +111,9 @@ func handleDeleteCustomAttribute(r *fastglue.Request) error { if err != nil || id <= 0 { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) } + if err := removeAttributeFromPreChatForms(app, id); err != nil { + return sendErrorEnvelope(r, err) + } if err = app.customAttribute.Delete(id); err != nil { return sendErrorEnvelope(r, err) } @@ -137,3 +142,96 @@ func validateCustomAttribute(app *App, attribute cmodels.CustomAttribute) error } return nil } + +func removeAttributeFromPreChatForms(app *App, attributeID int) error { + inboxes, err := app.inbox.GetAll() + if err != nil { + return err + } + var failed int + for _, inb := range inboxes { + if inb.Channel != livechat.ChannelLiveChat { + continue + } + config, changed, err := stripPreChatFormAttribute(inb.Config, attributeID) + if err != nil { + app.lo.Error("error parsing live chat config", "id", inb.ID, "error", err) + failed++ + continue + } + if !changed { + continue + } + if err := app.inbox.UpdateConfig(inb.ID, config); err != nil { + failed++ + continue + } + if err := reloadInbox(app, inb.ID); err != nil { + app.lo.Error("error reloading inbox", "id", inb.ID, "error", err) + failed++ + } + } + if failed > 0 { + app.lo.Error("error clearing deleted custom attribute from live chat inboxes", "inboxes", failed) + return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil) + } + return nil +} + +func stripPreChatFormAttribute(raw json.RawMessage, attributeID int) (json.RawMessage, bool, error) { + var config map[string]any + if len(raw) == 0 { + return raw, false, nil + } + if err := json.Unmarshal(raw, &config); err != nil { + return raw, false, err + } + form, ok := config["prechat_form"].(map[string]any) + if !ok { + return raw, false, nil + } + var changed bool + for _, holder := range []map[string]any{form, asObject(form["visitors"]), asObject(form["users"])} { + if holder == nil { + continue + } + fields, ok := holder["fields"].([]any) + if !ok { + continue + } + kept := make([]any, 0, len(fields)) + for _, field := range fields { + if attributeIDOf(field) == attributeID { + changed = true + continue + } + kept = append(kept, field) + } + holder["fields"] = kept + } + if !changed { + return raw, false, nil + } + updated, err := json.Marshal(config) + if err != nil { + return raw, false, err + } + return updated, true, nil +} + +func asObject(value any) map[string]any { + object, _ := value.(map[string]any) + return object +} + +func attributeIDOf(field any) int { + object, ok := field.(map[string]any) + if !ok { + return 0 + } + id, ok := object["custom_attribute_id"].(float64) + if !ok { + return 0 + } + return int(id) +} diff --git a/cmd/custom_attributes_test.go b/cmd/custom_attributes_test.go new file mode 100644 index 00000000..5a5981e9 --- /dev/null +++ b/cmd/custom_attributes_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestStripPreChatFormAttribute(t *testing.T) { + raw := json.RawMessage(`{ + "brand_name":"Acme", + "prechat_form":{ + "fields":[{"key":"name"},{"key":"plan","custom_attribute_id":7}], + "visitors":{"fields":[{"key":"plan","custom_attribute_id":7},{"key":"email"}]}, + "users":{"fields":[{"key":"email"}]} + } + }`) + + updated, changed, err := stripPreChatFormAttribute(raw, 7) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("expected config to change") + } + + var config struct { + BrandName string `json:"brand_name"` + PreChatForm struct { + Fields []map[string]any `json:"fields"` + Visitors struct { + Fields []map[string]any `json:"fields"` + } `json:"visitors"` + Users struct { + Fields []map[string]any `json:"fields"` + } `json:"users"` + } `json:"prechat_form"` + } + if err := json.Unmarshal(updated, &config); err != nil { + t.Fatal(err) + } + if config.BrandName != "Acme" { + t.Fatalf("brand name = %q", config.BrandName) + } + if len(config.PreChatForm.Fields) != 1 || config.PreChatForm.Fields[0]["key"] != "name" { + t.Fatalf("fields = %v", config.PreChatForm.Fields) + } + if len(config.PreChatForm.Visitors.Fields) != 1 || config.PreChatForm.Visitors.Fields[0]["key"] != "email" { + t.Fatalf("visitor fields = %v", config.PreChatForm.Visitors.Fields) + } + if len(config.PreChatForm.Users.Fields) != 1 { + t.Fatalf("user fields = %v", config.PreChatForm.Users.Fields) + } + + if _, changed, err := stripPreChatFormAttribute(raw, 8); err != nil || changed { + t.Fatalf("unused attribute changed = %v, err = %v", changed, err) + } + if _, changed, err := stripPreChatFormAttribute(json.RawMessage(`{}`), 7); err != nil || changed { + t.Fatalf("config without a pre-chat form changed = %v, err = %v", changed, err) + } +} diff --git a/cmd/handlers.go b/cmd/handlers.go index 2eedfc45..77645569 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -91,6 +91,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.GET("/api/v1/messages/search", perm(handleSearchMessages, "messages:read")) g.GET("/api/v1/contacts/search", perm(handleSearchContacts, "contacts:read")) + // New paginated search bar routes with better filter support and pagination. + g.GET("/api/v1/search/conversations", perm(handlePaginatedSearchConversations, "conversations:read")) + g.GET("/api/v1/search/messages", perm(handlePaginatedSearchMessages, "messages:read")) + // Views. g.GET("/api/v1/views/me", perm(handleGetUserViews, "view:manage")) g.POST("/api/v1/views/me", perm(handleCreateUserView, "view:manage")) @@ -152,6 +156,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // Contacts. g.GET("/api/v1/contacts", perm(handleGetContacts, "contacts:read_all")) + g.POST("/api/v1/contacts", perm(handleCreateContact, "contacts:write")) g.GET("/api/v1/contacts/{id}", perm(handleGetContact, "contacts:read")) g.PUT("/api/v1/contacts/{id}", perm(handleUpdateContact, "contacts:write")) g.PUT("/api/v1/contacts/{id}/block", perm(handleBlockContact, "contacts:block")) @@ -183,6 +188,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // Inboxes. g.GET("/api/v1/inboxes", auth(handleGetInboxes)) + g.GET("/api/v1/inboxes/{id}/campaign-stats", perm(handleCampaignStats, "inboxes:manage")) g.GET("/api/v1/inboxes/{id}", perm(handleGetInbox, "inboxes:manage")) g.POST("/api/v1/inboxes", perm(handleCreateInbox, "inboxes:manage")) g.PUT("/api/v1/inboxes/{id}/toggle", perm(handleToggleInbox, "inboxes:manage")) @@ -252,6 +258,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // AI completions. g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts)) + g.GET("/api/v1/ai/prompts/{id}", perm(handleGetAIPrompt, "ai:manage")) + g.POST("/api/v1/ai/prompts", perm(handleCreateAIPrompt, "ai:manage")) + g.PUT("/api/v1/ai/prompts/{id}", perm(handleUpdateAIPrompt, "ai:manage")) + g.DELETE("/api/v1/ai/prompts/{id}", perm(handleDeleteAIPrompt, "ai:manage")) g.POST("/api/v1/ai/completion", auth(handleAICompletion)) // AI provider config (completion / embedding). @@ -278,6 +288,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.POST("/api/v1/ai/summarize", perm(handleAISummarizeConversation, "messages:write_private")) g.POST("/api/v1/ai/suggest-tags", auth(handleAISuggestTags)) g.POST("/api/v1/ai/copilot", auth(handleAICopilot)) + g.POST("/api/v1/ai/tool-runs/{id}/approve", auth(handleApproveAIToolRun)) + g.POST("/api/v1/ai/tool-runs/{id}/decline", auth(handleDeclineAIToolRun)) g.GET("/api/v1/ai/copilot/messages", auth(handleGetCopilotMessages)) g.DELETE("/api/v1/ai/copilot/messages", auth(handleClearCopilotMessages)) @@ -321,6 +333,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.PUT("/api/v1/articles/{id}", perm(clearsHCCache(handleUpdateArticle), "help_center:manage")) g.PUT("/api/v1/articles/{id}/collection", perm(clearsHCCache(handleMoveArticle), "help_center:manage")) g.PUT("/api/v1/articles/{id}/status", perm(clearsHCCache(handleUpdateArticleStatus), "help_center:manage")) + g.GET("/api/v1/help-centers/{id}/linkable-articles", perm(handleGetLinkableArticles, "help_center:manage")) + g.PUT("/api/v1/articles/{id}/link-translation", perm(clearsHCCache(handleLinkArticleTranslation), "help_center:manage")) + g.PUT("/api/v1/articles/{id}/unlink-translation", perm(clearsHCCache(handleUnlinkArticleTranslation), "help_center:manage")) g.GET("/api/v1/help-centers/{id}/insights", perm(handleGetHelpCenterInsights, "help_center:manage")) // Public help center JSON API. @@ -349,6 +364,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.PUT("/api/v1/notifications/read-all", auth(handleMarkAllNotificationsAsRead)) g.DELETE("/api/v1/notifications/{id}", auth(handleDeleteNotification)) g.DELETE("/api/v1/notifications", auth(handleDeleteAllNotifications)) + g.GET("/api/v1/notifications/preferences", auth(handleGetNotificationPreferences)) + g.PUT("/api/v1/notifications/preferences", auth(handleUpdateNotificationPreferences)) + g.POST("/api/v1/notifications/push-subscriptions", auth(handleCreatePushSubscription)) + g.DELETE("/api/v1/notifications/push-subscriptions", auth(handleDeletePushSubscription)) // WebSocket. g.GET("/ws", auth(func(r *fastglue.Request) error { @@ -361,13 +380,20 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // Widget APIs. g.GET("/api/v1/widget/chat/settings/launcher", rateLimit(validateWidgetInbox(handleGetChatLauncherSettings), "widget")) g.GET("/api/v1/widget/chat/settings", rateLimit(validateWidgetInbox(handleGetChatSettings), "widget")) + g.POST("/api/v1/widget/chat/campaigns/next", rateLimit(optionalWidgetAuth(handleWidgetCampaign), "widget")) + g.POST("/api/v1/widget/chat/campaigns/event", rateLimit(optionalWidgetAuth(handleWidgetCampaignEvent), "widget")) + g.GET("/api/v1/widget/chat/help", rateLimit(optionalWidgetAuth(handleWidgetHelp), "widget")) + g.GET("/api/v1/widget/chat/help/search", rateLimit(optionalWidgetAuth(handleWidgetHelpSearch), "widget")) + g.GET("/api/v1/widget/chat/help/articles/{article_slug}", rateLimit(optionalWidgetAuth(handleWidgetHelpArticle), "widget")) g.POST("/api/v1/widget/chat/auth/exchange", rateLimit(validateWidgetInbox(handleAuthExchange), "widget")) g.GET("/api/v1/widget/chat/auth/me", rateLimit(widgetAuth(handleWidgetAuthMe), "widget")) g.POST("/api/v1/widget/chat/conversations/init", rateLimit(widgetAuth(handleChatInit), "widget")) g.GET("/api/v1/widget/chat/conversations", rateLimit(widgetAuth(handleGetConversations), "widget")) g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", rateLimit(widgetAuth(handleChatUpdateLastSeen), "widget")) g.GET("/api/v1/widget/chat/conversations/{uuid}", rateLimit(widgetAuth(handleChatGetConversation), "widget")) + g.GET("/api/v1/widget/chat/conversations/{uuid}/transcript", rateLimit(widgetAuth(handleWidgetTranscript), "widget")) g.POST("/api/v1/widget/chat/conversations/{uuid}/message", rateLimit(widgetAuth(handleChatSendMessage), "widget")) + g.POST("/api/v1/widget/chat/conversations/{uuid}/handoff-form", rateLimit(widgetAuth(handleChatSubmitHandoffForm), "widget")) g.POST("/api/v1/widget/media/upload", rateLimit(widgetAuth(handleWidgetMediaUpload), "widget")) // getAndHead registers both methods: uptime checkers and link validators probe with HEAD. @@ -395,6 +421,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.GET("/assets/{all:*}", serveFrontendStaticFiles) g.GET("/widget/assets/{all:*}", serveWidgetStaticFiles) g.GET("/images/{all:*}", serveFrontendStaticFiles) + g.GET("/manifest.webmanifest", serveManifest) + g.GET("/sw.js", serveServiceWorker) g.GET("/static/public/{all:*}", serveStaticFiles) // Public pages. @@ -514,6 +542,27 @@ func serveFrontendStaticFiles(r *fastglue.Request) error { return nil } +func serveManifest(r *fastglue.Request) error { + return serveMainFrontendFile(r, "manifest.webmanifest", "application/manifest+json", "no-cache") +} + +func serveServiceWorker(r *fastglue.Request) error { + r.RequestCtx.Response.Header.Set("Service-Worker-Allowed", "/") + return serveMainFrontendFile(r, "sw.js", "application/javascript", "no-cache") +} + +func serveMainFrontendFile(r *fastglue.Request, name, contentType, cacheControl string) error { + app := r.Context.(*App) + file, err := app.fs.Get(filepath.Join(frontendDir, name)) + if err != nil { + return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.T("validation.notFoundFile"), nil, envelope.NotFoundError) + } + r.RequestCtx.Response.Header.Set("Content-Type", contentType) + r.RequestCtx.Response.Header.Set("Cache-Control", cacheControl) + r.RequestCtx.SetBody(file.ReadBytes()) + return nil +} + // serveWidgetStaticFiles serves widget static assets from the embedded filesystem. func serveWidgetStaticFiles(r *fastglue.Request) error { app := r.Context.(*App) @@ -542,7 +591,9 @@ func serveWidgetJS(r *fastglue.Request) error { app := r.Context.(*App) r.RequestCtx.Response.Header.Set("Content-Type", "application/javascript") - r.RequestCtx.Response.Header.Set("Cache-Control", "no-cache") + r.RequestCtx.Response.Header.Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + r.RequestCtx.Response.Header.Set("Pragma", "no-cache") + r.RequestCtx.Response.Header.Set("Expires", "0") file, err := app.fs.Get("static/widget.js") if err != nil { diff --git a/cmd/helpcenter.go b/cmd/helpcenter.go index b5391334..43f4e584 100644 --- a/cmd/helpcenter.go +++ b/cmd/helpcenter.go @@ -1,6 +1,7 @@ package main import ( + "cmp" "encoding/json" "encoding/xml" "fmt" @@ -21,6 +22,7 @@ import ( "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/helpcenter" hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" "github.com/abhinavxd/libredesk/internal/media" "github.com/abhinavxd/libredesk/internal/stringutil" realip "github.com/ferluci/fast-realip" @@ -138,11 +140,22 @@ type localeLink struct { Path string } +type helpArticleResponse struct { + hcmodels.Article + Translations []hcmodels.ArticleTranslation `json:"translations"` +} + type previewTOCItem struct { ID string Title string } +type colorSchemeResult struct { + dark bool + showToggle bool + followSystem bool +} + func (w helpCenterCacheLogWriter) Write(p []byte) (int, error) { w.lo.Error("help center page cache: " + strings.TrimSpace(string(p))) return len(p), nil @@ -255,7 +268,7 @@ func handleHelpCenterPreview(r *fastglue.Request) error { "Data": map[string]interface{}{ "Title": helpCenter.PageTitle, "LandingHero": true, - "HelpCenter": helpCenterTemplateData(app, helpCenter, locale), + "HelpCenter": helpCenterTemplateData(app, r, helpCenter, locale), "Tree": tree.Tree, "Popular": popular, }, @@ -445,7 +458,11 @@ func handleGetArticle(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } - return r.SendEnvelope(article) + translations, err := app.helpcenter.GetArticleTranslations(id) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(helpArticleResponse{Article: article, Translations: translations}) } // handleCreateArticle creates a new article. @@ -570,6 +587,67 @@ func handleUpdateArticleStatus(r *fastglue.Request) error { return r.SendEnvelope(article) } +// handleGetLinkableArticles lists articles in the other locales that can still be linked as a translation. +func handleGetLinkableArticles(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + excludeLocale = strings.TrimSpace(string(r.RequestCtx.QueryArgs().Peek("exclude_locale"))) + ) + if id <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError) + } + if excludeLocale == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`exclude_locale`"), nil, envelope.InputError) + } + articles, err := app.helpcenter.GetLinkableArticles(id, excludeLocale) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(articles) +} + +// handleLinkArticleTranslation moves an existing article into this article's translation group. +func handleLinkArticleTranslation(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + req = struct { + TranslationOfID int `json:"translation_of_id"` + }{} + id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + ) + if id <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError) + } + if err := r.Decode(&req, "json"); err != nil { + return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil)) + } + if req.TranslationOfID <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`translation_of_id`"), nil, envelope.InputError) + } + article, err := app.helpcenter.LinkArticleTranslation(id, req.TranslationOfID) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(article) +} + +// handleUnlinkArticleTranslation detaches an article from its translation group. +func handleUnlinkArticleTranslation(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + ) + if id <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError) + } + article, err := app.helpcenter.UnlinkArticleTranslation(id) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(article) +} + // handleRedirectHelpCenterHome redirects bare /hc/{slug} to the default-locale home so the locale is always in the path. func handleRedirectHelpCenterHome(r *fastglue.Request) error { var ( @@ -583,8 +661,12 @@ func handleRedirectHelpCenterHome(r *fastglue.Request) error { if redirectHelpCenterCanonicalHost(r, helpCenter) { return nil } + uri := helpCenterHomePath(helpCenter, helpCenter.DefaultLocale) + if qs := r.RequestCtx.URI().QueryString(); len(qs) > 0 { + uri += "?" + string(qs) + } // 302, not 301: the default locale is mutable and browsers cache permanent redirects. - redirectPath(r.RequestCtx, helpCenterHomePath(helpCenter, helpCenter.DefaultLocale), fasthttp.StatusFound) + redirectPath(r.RequestCtx, uri, fasthttp.StatusFound) return nil } @@ -620,7 +702,7 @@ func handleShowHelpCenterHome(r *fastglue.Request) error { theme = helpCenterTheme(tree.HelpCenter) metaDescription = firstNonEmpty(tree.HelpCenter.MetaDescription, theme.Header.Heading) ) - data := helpCenterTemplateData(app, tree.HelpCenter, locale) + data := helpCenterTemplateData(app, r, tree.HelpCenter, locale) return renderHelpCenterPage(r, hcPageName(helpCenter, "help-center"), map[string]interface{}{ "L": localeI18n(app, locale), "Data": map[string]interface{}{ @@ -674,7 +756,7 @@ func handleShowHelpCenterCollection(r *fastglue.Request) error { root = helpCenterBaseURL(app, helpCenter) pathFor = func(l string) string { return collectionPath(helpCenter, l, collection.Slug) } ) - data := helpCenterTemplateData(app, helpCenter, locale) + data := helpCenterTemplateData(app, r, helpCenter, locale) return renderHelpCenterPage(r, hcPageName(helpCenter, "help-collection"), map[string]interface{}{ "L": localeI18n(app, locale), "Data": map[string]interface{}{ @@ -736,18 +818,27 @@ func handleShowHelpCenterArticle(r *fastglue.Request) error { if err != nil { related = nil } - translated, err := app.helpcenter.GetPublishedArticleLocales(slug, article.Slug) + translations, err := app.helpcenter.GetPublishedArticleTranslations(slug, article.ID) if err != nil { - translated = []string{locale} + translations = nil + } + translated := make([]string, 0, len(translations)+1) + slugByLocale := map[string]string{locale: article.Slug} + for _, t := range translations { + translated = append(translated, t.Locale) + slugByLocale[t.Locale] = t.Slug + } + if !slices.Contains(translated, locale) { + translated = append(translated, locale) } var ( root = helpCenterBaseURL(app, helpCenter) - pathFor = func(l string) string { return articlePath(helpCenter, l, article.Slug) } + pathFor = func(l string) string { return articlePath(helpCenter, l, slugByLocale[l]) } metaDescription = firstNonEmpty(article.MetaDescription, article.Excerpt) metaTitle = firstNonEmpty(article.MetaTitle, fmt.Sprintf("%s - %s", article.Title, helpCenter.Name)) ogImage = absoluteURL(root, publicAssetPaths(app, firstNonEmpty(article.MetaImageURL, helpCenterTheme(helpCenter).LogoURL))) ) - data := helpCenterTemplateData(app, helpCenter, locale) + data := helpCenterTemplateData(app, r, helpCenter, locale) return renderHelpCenterPage(r, hcPageName(helpCenter, "help-article"), map[string]interface{}{ "L": localeI18n(app, locale), "Data": map[string]interface{}{ @@ -770,6 +861,7 @@ func handleShowHelpCenterArticle(r *fastglue.Request) error { "Tree": sidebarTree(app, helpCenter, locale), "ActiveCollection": collection.Slug, "ActiveArticle": article.Slug, + "NoIndex": isEmbedRequest(r), "Content": template.HTML(publicAssetPaths(app, stringutil.DeferOffscreenImages(article.Content))), }, }) @@ -803,7 +895,7 @@ func handleHelpCenterSearch(r *fastglue.Request) error { } } var ( - data = helpCenterTemplateData(app, helpCenter, locale) + data = helpCenterTemplateData(app, r, helpCenter, locale) lcl = localeI18n(app, locale) pathFor = func(l string) string { return searchPath(helpCenter, l) } ) @@ -1153,9 +1245,11 @@ func cacheHCPage(h fastglue.FastRequestHandler, noIndex bool) fastglue.FastReque } r.RequestCtx.Response.Header.Set("X-Cache", status) r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff") - r.RequestCtx.Response.Header.Set("X-Frame-Options", "SAMEORIGIN") + if !isEmbedRequest(r) { + r.RequestCtx.Response.Header.Set("X-Frame-Options", "SAMEORIGIN") + } r.RequestCtx.Response.Header.Set("Referrer-Policy", "strict-origin-when-cross-origin") - if noIndex || isMarkdownRequest(r) { + if noIndex || isEmbedRequest(r) || isMarkdownRequest(r) { r.RequestCtx.Response.Header.Set("X-Robots-Tag", noIndexHeader) } } @@ -1163,6 +1257,11 @@ func cacheHCPage(h fastglue.FastRequestHandler, noIndex bool) fastglue.FastReque } } +// isEmbedRequest reports whether the page is framed by the chat widget. +func isEmbedRequest(r *fastglue.Request) bool { + return r.RequestCtx.QueryArgs().GetBool("embed") +} + func isMarkdownRequest(r *fastglue.Request) bool { slug, _ := r.RequestCtx.UserValue("article_slug").(string) return strings.HasSuffix(slug, markdownSlugExtension) @@ -1678,7 +1777,7 @@ func sidebarTree(app *App, hc hcmodels.HelpCenter, locale string) []hcmodels.Tre } // helpCenterTemplateData shapes a help center row for the public templates. -func helpCenterTemplateData(app *App, hc hcmodels.HelpCenter, locale string) map[string]interface{} { +func helpCenterTemplateData(app *App, r *fastglue.Request, hc hcmodels.HelpCenter, locale string) map[string]interface{} { theme := helpCenterTheme(hc) theme.Favicon = publicAssetPaths(app, theme.Favicon) theme.Header.BackgroundImage = publicAssetPaths(app, theme.Header.BackgroundImage) @@ -1686,6 +1785,8 @@ func helpCenterTemplateData(app *App, hc hcmodels.HelpCenter, locale string) map if pageTemplate == "" { pageTemplate = hcmodels.TemplateClassic } + embed := isEmbedRequest(r) + scheme := resolveColorScheme(theme.ColorScheme, string(r.RequestCtx.QueryArgs().Peek("theme")), embed) return map[string]interface{}{ "Slug": hc.Slug, "Name": hc.Name, @@ -1695,7 +1796,11 @@ func helpCenterTemplateData(app *App, hc hcmodels.HelpCenter, locale string) map "PageTitle": hc.PageTitle, "HeaderText": theme.Header.Heading, "LogoURL": publicAssetPaths(app, theme.LogoURL), + "LogoURLDark": publicAssetPaths(app, cmp.Or(theme.LogoURLDark, theme.LogoURL)), "Color": theme.Color, + "ColorDark": cmp.Or(theme.ColorDark, theme.Color), + "ShowThemeToggle": scheme.showToggle, + "FollowSystemTheme": scheme.followSystem, "DefaultLocale": hc.DefaultLocale, "CurrentLocale": locale, "OGLocale": strings.ReplaceAll(locale, "-", "_"), @@ -1704,15 +1809,31 @@ func helpCenterTemplateData(app *App, hc hcmodels.HelpCenter, locale string) map "NavLinks": theme.NavLinks, "Theme": theme, "ThemeCSS": buildThemeCSSVars(theme), + "ThemeCSSDark": buildDarkThemeCSSVars(theme), "AnnouncementKey": announcementKey(hc.Slug, theme.Announcement), "TaglineHTML": template.HTML(helpcenter.RenderInlineMarkdown(theme.Tagline)), "FooterTaglineHTML": template.HTML(helpcenter.RenderInlineMarkdown(theme.Footer.Tagline)), "AnnouncementHTML": template.HTML(helpcenter.RenderInlineMarkdown(theme.Announcement.Text)), "CustomCSS": template.CSS(hc.CustomCSS), "CustomJS": template.JS(hc.CustomJS), + "WidgetInboxUUID": livechatWidgetInboxUUID(app, hc), + "WidgetRootURL": helpCenterRootURL(app), + "Embed": embed, + "Dark": scheme.dark, } } +func livechatWidgetInboxUUID(app *App, hc hcmodels.HelpCenter) string { + if !hc.LivechatInboxID.Valid { + return "" + } + inb, err := app.inbox.GetDBRecord(hc.LivechatInboxID.Int) + if err != nil || !inb.Enabled || inb.Channel != livechat.ChannelLiveChat { + return "" + } + return inb.UUID +} + // announcementKey keys the dismissal on help center and content, so an edited announcement reappears for visitors who dismissed the old one. func announcementKey(hcSlug string, a hcmodels.AnnouncementTheme) string { if a.Text == "" { @@ -1762,6 +1883,38 @@ func buildThemeCSSVars(t hcmodels.Theme) template.CSS { return template.CSS(b.String()) } +// buildDarkThemeCSSVars swaps in the dark footer colors and resets light-only colors to the dark defaults. +func buildDarkThemeCSSVars(t hcmodels.Theme) template.CSS { + var b strings.Builder + if t.Header.TextColor != "" && !hasHeaderBackground(t.Header) { + b.WriteString("--hc-header-text:initial;") + } + writeDarkVar(&b, "--hc-footer-bg", t.Footer.BackgroundColorDark, t.Footer.BackgroundColor) + writeDarkVar(&b, "--hc-footer-text", t.Footer.TextColorDark, t.Footer.TextColor) + return template.CSS(b.String()) +} + +func writeDarkVar(b *strings.Builder, name, dark, light string) { + switch { + case dark != "": + fmt.Fprintf(b, "%s:%s;", name, dark) + case light != "": + fmt.Fprintf(b, "%s:initial;", name) + } +} + +func hasHeaderBackground(h hcmodels.HeaderTheme) bool { + switch h.BackgroundType { + case "image": + return h.BackgroundImage != "" + case "gradient": + return h.GradientFrom != "" && h.GradientTo != "" + case "solid": + return h.BackgroundColor != "" + } + return false +} + // renderHelpCenterNotFound renders the help center's themed 404, falling back to the // generic error page when the help center is nil. func renderHelpCenterNotFound(r *fastglue.Request, hc *hcmodels.HelpCenter) error { @@ -1788,7 +1941,7 @@ func renderHelpCenterStatusPage(r *fastglue.Request, hc *hcmodels.HelpCenter, st if !ok { locale = helpCenter.DefaultLocale } - data := helpCenterTemplateData(app, helpCenter, locale) + data := helpCenterTemplateData(app, r, helpCenter, locale) lcl := localeI18n(app, locale) r.RequestCtx.Response.Header.Set("X-Robots-Tag", noIndexHeader) rerr := app.tmpl.RenderWebPage(r.RequestCtx, hcPageName(helpCenter, "help-notfound"), map[string]interface{}{ @@ -1832,6 +1985,15 @@ func validateHelpCenter(app *App, req *helpcenter.HelpCenterRequest) error { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`page_title`"), nil) } req.Theme = json.RawMessage(publicAssetPaths(app, string(req.Theme))) + if req.LivechatInboxID.Valid { + inb, err := app.inbox.GetDBRecord(req.LivechatInboxID.Int) + if err != nil { + return err + } + if inb.Channel != livechat.ChannelLiveChat { + return envelope.NewError(envelope.InputError, app.i18n.T("helpCenter.invalidLivechatInbox"), nil) + } + } return nil } @@ -1970,7 +2132,7 @@ func renderHelpCenterArticlePreview(r *fastglue.Request, helpCenter hcmodels.Hel "Data": map[string]interface{}{ "Title": article.Title, "ModifiedTime": article.UpdatedAt.Format(time.RFC3339), - "HelpCenter": helpCenterTemplateData(app, helpCenter, locale), + "HelpCenter": helpCenterTemplateData(app, r, helpCenter, locale), "Article": article, "AuthorInitial": authorInitial(article), "Collection": collection, @@ -1996,3 +2158,14 @@ func redirectPath(ctx *fasthttp.RequestCtx, uri string, statusCode int) { ctx.SetStatusCode(statusCode) ctx.Response.SetBodyString("") } + +// resolveColorScheme lets the widget embed and the admin preview override the admin's scheme. +func resolveColorScheme(scheme, requested string, embed bool) colorSchemeResult { + res := colorSchemeResult{dark: scheme == hcmodels.ColorSchemeDark} + if requested != "" || embed { + res.dark = requested == hcmodels.ColorSchemeDark + } + res.showToggle = scheme == hcmodels.ColorSchemeSystem && !embed + res.followSystem = res.showToggle && requested == "" + return res +} diff --git a/cmd/helpcenter_theme_test.go b/cmd/helpcenter_theme_test.go new file mode 100644 index 00000000..614e1092 --- /dev/null +++ b/cmd/helpcenter_theme_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "testing" + + hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models" +) + +func TestResolveColorScheme(t *testing.T) { + const ( + system = hcmodels.ColorSchemeSystem + light = hcmodels.ColorSchemeLight + dark = hcmodels.ColorSchemeDark + ) + tests := []struct { + name string + scheme string + requested string + embed bool + want colorSchemeResult + }{ + {"light site", light, "", false, colorSchemeResult{}}, + {"dark site", dark, "", false, colorSchemeResult{dark: true}}, + {"system site", system, "", false, colorSchemeResult{showToggle: true, followSystem: true}}, + {"preview dark of a light site", light, dark, false, colorSchemeResult{dark: true}}, + {"preview light of a dark site", dark, light, false, colorSchemeResult{}}, + {"preview of a system site keeps the toggle", system, dark, false, colorSchemeResult{dark: true, showToggle: true}}, + {"embed in a light widget on a dark site", dark, "", true, colorSchemeResult{}}, + {"embed in a dark widget", light, dark, true, colorSchemeResult{dark: true}}, + {"embed on a system site", system, dark, true, colorSchemeResult{dark: true}}, + {"unknown requested value", dark, "purple", false, colorSchemeResult{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolveColorScheme(tt.scheme, tt.requested, tt.embed); got != tt.want { + t.Errorf("resolveColorScheme(%q, %q, %v) = %+v, want %+v", tt.scheme, tt.requested, tt.embed, got, tt.want) + } + }) + } +} + +func TestBuildDarkThemeCSSVars(t *testing.T) { + tests := []struct { + name string + header hcmodels.HeaderTheme + footer hcmodels.FooterTheme + want string + }{ + {"nothing set", hcmodels.HeaderTheme{}, hcmodels.FooterTheme{}, ""}, + {"header text on default header", hcmodels.HeaderTheme{TextColor: "#000"}, hcmodels.FooterTheme{}, "--hc-header-text:initial;"}, + {"header text on solid header", hcmodels.HeaderTheme{BackgroundType: "solid", BackgroundColor: "#fff", TextColor: "#000"}, hcmodels.FooterTheme{}, ""}, + {"header text on image header", hcmodels.HeaderTheme{BackgroundType: "image", BackgroundImage: "/a.png", TextColor: "#000"}, hcmodels.FooterTheme{}, ""}, + {"header text on half-set gradient", hcmodels.HeaderTheme{BackgroundType: "gradient", GradientFrom: "#fff", TextColor: "#000"}, hcmodels.FooterTheme{}, "--hc-header-text:initial;"}, + {"footer text on default footer", hcmodels.HeaderTheme{}, hcmodels.FooterTheme{TextColor: "#000"}, "--hc-footer-text:initial;"}, + {"light footer colors only", hcmodels.HeaderTheme{}, hcmodels.FooterTheme{BackgroundColor: "#fff", TextColor: "#000"}, "--hc-footer-bg:initial;--hc-footer-text:initial;"}, + {"dark footer colors", hcmodels.HeaderTheme{}, hcmodels.FooterTheme{BackgroundColor: "#fff", TextColor: "#000", BackgroundColorDark: "#111", TextColorDark: "#eee"}, "--hc-footer-bg:#111;--hc-footer-text:#eee;"}, + {"dark footer text only", hcmodels.HeaderTheme{}, hcmodels.FooterTheme{TextColorDark: "#eee"}, "--hc-footer-text:#eee;"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := string(buildDarkThemeCSSVars(hcmodels.Theme{Header: tt.header, Footer: tt.footer})) + if got != tt.want { + t.Errorf("buildDarkThemeCSSVars() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/cmd/inboxes.go b/cmd/inboxes.go index 290e91f5..afbf636c 100644 --- a/cmd/inboxes.go +++ b/cmd/inboxes.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/httputil" @@ -17,6 +18,13 @@ import ( "github.com/zerodha/fastglue" ) +var hexColorRegex = regexp.MustCompile(`^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$`) + +const ( + minLauncherIconScale = 40 + maxLauncherIconScale = 100 +) + // handleGetInboxes returns all inboxes func handleGetInboxes(r *fastglue.Request) error { var app = r.Context.(*App) @@ -43,6 +51,18 @@ func handleGetInbox(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } + if inbox.Channel == livechat.ChannelLiveChat { + var config livechat.Config + if err := json.Unmarshal(inbox.Config, &config); err != nil { + app.lo.Error("error parsing live chat config", "id", id, "error", err) + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) + } + inbox.Config, err = json.Marshal(config) + if err != nil { + app.lo.Error("error encoding live chat config", "id", id, "error", err) + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) + } + } if err := inbox.ClearPasswords(); err != nil { app.lo.Error("error clearing inbox passwords from response", "error", err) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) @@ -257,110 +277,133 @@ func validateInbox(app *App, inbox *imodels.Inbox, id int) error { // 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) + if err := json.Unmarshal(inbox.Config, &config); err != nil { + return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil) + } + if err := validateLiveChatSessionDuration(app, config.SessionDuration); err != nil { + return err + } + if err := validateWidgetFeatures(app, config); err != nil { + return err + } + // 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 continuity settings - required when linked email inbox is set. + if inbox.LinkedEmailInboxID.Valid && inbox.LinkedEmailInboxID.Int > 0 { + if config.Continuity.OfflineThreshold == "" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "offline_threshold"), nil) } - // Validate continuity settings - required when linked email inbox is set. - if inbox.LinkedEmailInboxID.Valid && inbox.LinkedEmailInboxID.Int > 0 { - if config.Continuity.OfflineThreshold == "" { - return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "offline_threshold"), nil) - } - if config.Continuity.MinEmailInterval == "" { - return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "min_email_interval"), nil) - } - if config.Continuity.MaxMessagesPerEmail == 0 { - return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "max_messages_per_email"), nil) - } + if config.Continuity.MinEmailInterval == "" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "min_email_interval"), nil) } - if config.Continuity.OfflineThreshold != "" { - d, err := time.ParseDuration(config.Continuity.OfflineThreshold) - if err != nil { - return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDuration", "name", "offline_threshold"), nil) - } - if d < time.Minute { - return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.minDuration", "name", "offline_threshold", "min", "1m"), nil) - } + if config.Continuity.MaxMessagesPerEmail == 0 { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "max_messages_per_email"), nil) } - if config.Continuity.MinEmailInterval != "" { - d, err := time.ParseDuration(config.Continuity.MinEmailInterval) - if err != nil { - return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDuration", "name", "min_email_interval"), nil) - } - if d < time.Minute { - return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.minDuration", "name", "min_email_interval", "min", "1m"), nil) - } + } + if config.Continuity.OfflineThreshold != "" { + d, err := time.ParseDuration(config.Continuity.OfflineThreshold) + if err != nil { + return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDuration", "name", "offline_threshold"), nil) } - if config.Continuity.MaxMessagesPerEmail != 0 { - if config.Continuity.MaxMessagesPerEmail < 1 || config.Continuity.MaxMessagesPerEmail > 100 { - return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.minmaxNumber", "min", "1", "max", "100"), nil) - } + if d < time.Minute { + return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.minDuration", "name", "offline_threshold", "min", "1m"), nil) } + } + if config.Continuity.MinEmailInterval != "" { + d, err := time.ParseDuration(config.Continuity.MinEmailInterval) + if err != nil { + return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDuration", "name", "min_email_interval"), nil) + } + if d < time.Minute { + return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.minDuration", "name", "min_email_interval", "min", "1m"), nil) + } + } + if config.Continuity.MaxMessagesPerEmail != 0 { + if config.Continuity.MaxMessagesPerEmail < 1 || config.Continuity.MaxMessagesPerEmail > 100 { + return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.minmaxNumber", "min", "1", "max", "100"), nil) + } + } - // Validate colors. - hexColorRegex := regexp.MustCompile(`^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$`) - if config.Colors.Primary == "" { + for _, replies := range [][]string{config.Visitors.QuickReplies, config.Users.QuickReplies} { + if err := validateQuickReplies(app, replies); err != nil { + return err + } + } + + for _, branding := range []livechat.Branding{config.Branding.Light, config.Branding.Dark} { + if branding.Colors.Primary == "" { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "primary color"), nil) } - if !hexColorRegex.MatchString(config.Colors.Primary) { + if !hexColorRegex.MatchString(branding.Colors.Primary) { return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidColor"), nil) } - - // Validate launcher position. - if config.Launcher.Position != "left" && config.Launcher.Position != "right" { - return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil) + if branding.Launcher.Color != "" && !hexColorRegex.MatchString(branding.Launcher.Color) { + return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidColor"), nil) } - - // Validate launcher spacing: clamp to a sane range so a fat-fingered value doesn't push the launcher off-screen. - if config.Launcher.Spacing.Side < 0 || config.Launcher.Spacing.Side > 200 || - config.Launcher.Spacing.Bottom < 0 || config.Launcher.Spacing.Bottom > 200 { - return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil) - } - - // Validate home apps. - for _, ha := range config.HomeApps { - if ha.URL != "" && !httputil.IsValidHTTPURL(ha.URL) { - return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidUrl"), nil) - } - if ha.Type == livechat.HomeAppAnnouncement && ha.ImageURL != "" && !httputil.IsValidHTTPURL(ha.ImageURL) { - return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidUrl"), nil) - } - } - - // Validate home screen background image URL. - if config.HomeScreen.Background.ImageURL != "" && !httputil.IsValidHTTPURL(config.HomeScreen.Background.ImageURL) { + if branding.HomeScreen.Background.ImageURL != "" && !httputil.IsValidHTTPURL(branding.HomeScreen.Background.ImageURL) { return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidUrl"), nil) } - - // Validate URLs if set. - for _, u := range []string{config.LogoURL, config.Launcher.LogoURL, config.WebsiteURL} { + for _, u := range []string{branding.LogoURL, branding.Launcher.LogoURL} { if u != "" && !httputil.IsValidHTTPURL(u) { return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidUrl"), nil) } } + } - // Validate trusted domains. - // Valid formats: example.com, *.example.com, sub.example.com, example.com:8080 - for _, domain := range config.TrustedDomains { - d := strings.TrimSpace(domain) - if d == "" { - continue - } - if strings.Contains(d, "://") || strings.Contains(d, "/") || strings.Contains(d, " ") { - return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDomain", "domain", d), nil) - } - // Wildcard must be at the start followed by a dot. - if strings.Contains(d, "*") && !strings.HasPrefix(d, "*.") { - return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDomain", "domain", d), nil) - } + if config.Theme != livechat.ThemeSystem && config.Theme != livechat.ThemeLight && config.Theme != livechat.ThemeDark { + return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil) + } + + // Validate launcher position. + if config.Launcher.Position != "left" && config.Launcher.Position != "right" { + return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil) + } + + if config.Launcher.IconScale < minLauncherIconScale || config.Launcher.IconScale > maxLauncherIconScale { + return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil) + } + + // Validate launcher spacing: clamp to a sane range so a fat-fingered value doesn't push the launcher off-screen. + if config.Launcher.Spacing.Side < 0 || config.Launcher.Spacing.Side > 200 || + config.Launcher.Spacing.Bottom < 0 || config.Launcher.Spacing.Bottom > 200 { + return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil) + } + + for _, ha := range config.HomeApps { + if ha.URL != "" && !httputil.IsValidHTTPURL(ha.URL) { + return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidUrl"), nil) } + if ha.ImageURL != "" && !httputil.IsValidHTTPURL(ha.ImageURL) { + return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidUrl"), nil) + } + } - // Validate blocked IPs entries. - for _, entry := range config.BlockedIPs { - if !httputil.ValidateIPOrCIDR(entry) { - return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidIPOrCIDR", "entry", entry), nil) - } + if config.WebsiteURL != "" && !httputil.IsValidHTTPURL(config.WebsiteURL) { + return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidUrl"), nil) + } + + // Validate trusted domains. + // Valid formats: example.com, *.example.com, sub.example.com, example.com:8080 + for _, domain := range config.TrustedDomains { + d := strings.TrimSpace(domain) + if d == "" { + continue + } + if strings.Contains(d, "://") || strings.Contains(d, "/") || strings.Contains(d, " ") { + return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDomain", "domain", d), nil) + } + // Wildcard must be at the start followed by a dot. + if strings.Contains(d, "*") && !strings.HasPrefix(d, "*.") { + return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidDomain", "domain", d), nil) + } + } + + // Validate blocked IPs entries. + for _, entry := range config.BlockedIPs { + if !httputil.ValidateIPOrCIDR(entry) { + return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.invalidIPOrCIDR", "entry", entry), nil) } } @@ -391,6 +434,35 @@ func validateInbox(app *App, inbox *imodels.Inbox, id int) error { return nil } +func validateQuickReplies(app *App, replies []string) error { + if len(replies) > 6 { + return envelope.NewError(envelope.InputError, app.i18n.T("admin.inbox.livechat.quickReplies.limit"), nil) + } + for _, reply := range replies { + if strings.TrimSpace(reply) == "" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "quick reply"), nil) + } + if utf8.RuneCountInString(reply) > 120 { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.maxLength", "max", "120"), nil) + } + } + return nil +} + +func validateLiveChatSessionDuration(app *App, value string) error { + if value == "" { + return nil + } + duration, err := time.ParseDuration(value) + if err != nil { + return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidDuration"), nil) + } + if duration < time.Hour { + return envelope.NewError(envelope.InputError, app.i18n.Ts("validation.minDuration", "name", "session_duration", "min", "1h"), nil) + } + return nil +} + // validateEmailConfig validates the email inbox configuration. func validateEmailConfig(app *App, configJSON json.RawMessage) error { var cfg imodels.Config diff --git a/cmd/inboxes_test.go b/cmd/inboxes_test.go new file mode 100644 index 00000000..3145ce3c --- /dev/null +++ b/cmd/inboxes_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "strings" + "testing" +) + +func TestValidateQuickReplies(t *testing.T) { + app := newValidatorTestApp(t) + tests := []struct { + name string + replies []string + wantErr bool + }{ + {name: "unicode limit", replies: []string{strings.Repeat("界", 120)}}, + {name: "unicode over limit", replies: []string{strings.Repeat("界", 121)}, wantErr: true}, + {name: "blank", replies: []string{" "}, wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateQuickReplies(app, tc.replies) + if (err != nil) != tc.wantErr { + t.Fatalf("got error %v, want error %v", err, tc.wantErr) + } + }) + } +} + +func TestValidateLiveChatSessionDuration(t *testing.T) { + app := newValidatorTestApp(t) + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "legacy empty"}, + {name: "one hour", value: "1h"}, + {name: "one hour in minutes", value: "60m"}, + {name: "one hour in seconds", value: "3600s"}, + {name: "combined duration", value: "1h30m"}, + {name: "zero", value: "0", wantErr: true}, + {name: "below one hour", value: "59m59s", wantErr: true}, + {name: "below one hour in seconds", value: "3599s", wantErr: true}, + {name: "negative", value: "-1h", wantErr: true}, + {name: "invalid", value: "soon", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateLiveChatSessionDuration(app, tc.value) + if (err != nil) != tc.wantErr { + t.Fatalf("got error %v, want error %v", err, tc.wantErr) + } + }) + } +} diff --git a/cmd/init.go b/cmd/init.go index 1f2cfd76..a2e59d84 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -33,12 +33,14 @@ import ( "github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/inbox/channel/email" "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat/proactive" imodels "github.com/abhinavxd/libredesk/internal/inbox/models" "github.com/abhinavxd/libredesk/internal/macro" "github.com/abhinavxd/libredesk/internal/media" fs "github.com/abhinavxd/libredesk/internal/media/stores/localfs" "github.com/abhinavxd/libredesk/internal/media/stores/s3" notifier "github.com/abhinavxd/libredesk/internal/notification" + notificationchannels "github.com/abhinavxd/libredesk/internal/notification/channels" emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email" "github.com/abhinavxd/libredesk/internal/oidc" "github.com/abhinavxd/libredesk/internal/ratelimit" @@ -1049,7 +1051,7 @@ func initHelpCenter(db *sqlx.DB, i18n *i18n.I18n, indexer helpcenter.ArticleInde } // initAIAgent inits the autonomous AI agent manager. -func initAIAgent(db *sqlx.DB, i18n *i18n.I18n, aiManager *ai.Manager, convo *conversation.Manager, mediaManager *media.Manager, settingManager *setting.Manager, userManager *user.Manager, notifierService *notifier.Service, rdb *redis.Client) *aiagent.Manager { +func initAIAgent(db *sqlx.DB, i18n *i18n.I18n, aiManager *ai.Manager, convo *conversation.Manager, inboxManager *inbox.Manager, mediaManager *media.Manager, settingManager *setting.Manager, userManager *user.Manager, notifierService *notifier.Service, rdb *redis.Client) *aiagent.Manager { m, err := aiagent.New(aiagent.Opts{ DB: db, Lo: initLogger("ai_agent"), @@ -1057,7 +1059,7 @@ func initAIAgent(db *sqlx.DB, i18n *i18n.I18n, aiManager *ai.Manager, convo *con QueueSize: cmp.Or(ko.Int("ai_agent.queue_size"), 1000), MaxSteps: min(max(cmp.Or(ko.Int("ai_agent.max_steps"), 6), 1), 20), MaxHistoryMessages: min(max(cmp.Or(ko.Int("ai_agent.max_history_messages"), 30), 5), 100), - }, aiManager, convo, mediaManager, settingManager, userManager, notifierService, rdb) + }, aiManager, convo, inboxManager, mediaManager, settingManager, userManager, notifierService, rdb) if err != nil { log.Fatalf("error initializing AI agent manager: %v", err) } @@ -1065,12 +1067,15 @@ func initAIAgent(db *sqlx.DB, i18n *i18n.I18n, aiManager *ai.Manager, convo *con } // initSearch inits search manager. -func initSearch(db *sqlx.DB, i18n *i18n.I18n) *search.Manager { +func initSearch(db *sqlx.DB, i18n *i18n.I18n, convo *conversation.Manager) *search.Manager { lo := initLogger("search") m, err := search.New(search.Opts{ - DB: db, - Lo: lo, - I18n: i18n, + DB: db, + Lo: lo, + I18n: i18n, + FilterFields: conversation.ListFilterAllowedFields, + FilterRenderers: conversation.ListFilterRenderers, + FilterLocation: convo.FilterLocation, }) if err != nil { log.Fatalf("error initializing search manager: %v", err) @@ -1168,6 +1173,19 @@ func initUserNotification(db *sqlx.DB, i18n *i18n.I18n) *notifier.UserNotificati return m } +// initNotificationPreference inits the notification preference manager. +func initNotificationPreference(db *sqlx.DB, i18n *i18n.I18n) *notifier.PreferenceManager { + m, err := notifier.NewPreferenceManager(notifier.PreferenceManagerOpts{ + DB: db, + Lo: initLogger("notification-preference"), + I18n: i18n, + }) + if err != nil { + log.Fatalf("error initializing notification preference manager: %v", err) + } + return m +} + // initImporter inits the importer manager. func initImporter(i18n *i18n.I18n) *importer.Importer { return importer.New(importer.Opts{ @@ -1176,14 +1194,46 @@ func initImporter(i18n *i18n.I18n) *importer.Importer { }) } +func initNotificationEmailQueue(db *sqlx.DB, outbound *notifier.Service) *notifier.EmailQueue { + q, err := notifier.NewEmailQueue(notifier.EmailQueueOpts{ + DB: db, + Outbound: outbound, + Lo: initLogger("notification-email-queue"), + }) + if err != nil { + log.Fatalf("error initializing notification email queue: %v", err) + } + return q +} + +func initPushNotification(db *sqlx.DB, settings *setting.Manager, i18n *i18n.I18n) *notifier.PushManager { + m, err := notifier.NewPushManager(notifier.PushManagerOpts{ + DB: db, + Settings: settings, + Lo: initLogger("push-notification"), + I18n: i18n, + RootURL: ko.String("app.root_url"), + Concurrency: ko.MustInt("notification.concurrency"), + QueueSize: ko.MustInt("notification.queue_size"), + }) + if err != nil { + log.Fatalf("error initializing push notification manager: %v", err) + } + return m +} + // initNotifDispatcher initializes the notification dispatcher. -func initNotifDispatcher(userNotification *notifier.UserNotificationManager, outbound *notifier.Service, wsHub *ws.Hub, emailEnabled bool) *notifier.Dispatcher { +func initNotifDispatcher(userNotification *notifier.UserNotificationManager, prefs *notifier.PreferenceManager, push *notifier.PushManager, emailQueue *notifier.EmailQueue, wsHub *ws.Hub, emailEnabled bool) *notifier.Dispatcher { + providers := []notificationchannels.Provider{ + notificationchannels.NewInApp(userNotification, wsHub, initLogger("notification-in-app")), + } + if emailEnabled { + providers = append(providers, notificationchannels.NewEmail(emailQueue)) + } + providers = append(providers, notificationchannels.NewPush(push)) return notifier.NewDispatcher(notifier.DispatcherOpts{ - InApp: userNotification, - Outbound: outbound, - WSHub: wsHub, - EmailEnabled: emailEnabled, - Lo: initLogger("notification-dispatcher"), + Pipeline: notificationchannels.NewPipeline(providers...), + Prefs: prefs, }) } @@ -1270,3 +1320,11 @@ func initRateLimit(redisClient *redis.Client) *ratelimit.Limiter { return limiter } + +func initProactive(db *sqlx.DB, i18n *i18n.I18n) *proactive.Manager { + manager, err := proactive.New(proactive.Opts{DB: db, I18n: i18n, Lo: initLogger("proactive")}) + if err != nil { + log.Fatalf("error initializing proactive messages: %v", err) + } + return manager +} diff --git a/cmd/login.go b/cmd/login.go index 2cf3968e..250cb943 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -21,6 +21,17 @@ func handleLogin(r *fastglue.Request) error { loginReq loginRequest ) + if localLoginDisabledInConfig() { + oidcProviders, err := app.oidc.GetAll() + if err != nil { + return sendErrorEnvelope(r, err) + } + if !isLocalLoginEnabled(oidcProviders) { + return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("auth.localLoginDisabled"), nil, envelope.PermissionError) + } + app.lo.Warn("local login is disabled in config but no OIDC provider is enabled, allowing password login") + } + // Decode JSON request. if err := r.Decode(&loginReq, "json"); err != nil { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError) diff --git a/cmd/main.go b/cmd/main.go index 41db7ef6..61a01046 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -26,6 +26,7 @@ import ( "github.com/abhinavxd/libredesk/internal/colorlog" "github.com/abhinavxd/libredesk/internal/csat" customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat/proactive" "github.com/abhinavxd/libredesk/internal/macro" notifier "github.com/abhinavxd/libredesk/internal/notification" "github.com/abhinavxd/libredesk/internal/report" @@ -94,6 +95,7 @@ const ( // App is the global app context which is passed and injected in the http handlers. type App struct { + proactive *proactive.Manager ctx context.Context fs stuffbin.FileSystem consts atomic.Value @@ -126,6 +128,8 @@ type App struct { activityLog *activitylog.Manager notifier *notifier.Service userNotification *notifier.UserNotificationManager + notificationPref *notifier.PreferenceManager + pushNotification *notifier.PushManager customAttribute *customAttribute.Manager report *report.Manager webhook *webhook.Manager @@ -249,12 +253,15 @@ func main() { wsHub = initWS(user) notifier = initNotifier() userNotification = initUserNotification(db, i18n) - notifDispatcher = initNotifDispatcher(userNotification, notifier, wsHub, ko.Bool("notification.email.enabled")) + notificationPreference = initNotificationPreference(db, i18n) + pushNotification = initPushNotification(db, settings, i18n) + notificationEmailQueue = initNotificationEmailQueue(db, notifier) + notifDispatcher = initNotifDispatcher(userNotification, notificationPreference, pushNotification, notificationEmailQueue, wsHub, ko.Bool("notification.email.enabled")) automation = initAutomationEngine(db, i18n) ai = initAI(ctx, db, i18n, ssrfControl) 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) - aiAgent = initAIAgent(db, i18n, ai, conversation, media, settings, user, notifier, rdb) + aiAgent = initAIAgent(db, i18n, ai, conversation, inbox, media, settings, user, notifier, rdb) helpCenter = initHelpCenter(db, i18n, ai) autoassigner = initAutoAssigner(team, user, conversation) rateLimiter = initRateLimit(rdb) @@ -285,6 +292,10 @@ func main() { go conversation.RunDraftCleaner(ctx, draftRetentionDuration) go userNotification.RunNotificationCleaner(ctx) go helpCenter.RunSearchLogCleaner(ctx) + if ko.Bool("notification.email.enabled") { + go notificationEmailQueue.Run(ctx) + } + go pushNotification.Run(ctx) go aiAgent.Run(ctx, cmp.Or(ko.Int("ai_agent.worker_count"), 10)) go ai.Run(ctx) @@ -315,7 +326,7 @@ func main() { authz: initAuthz(i18n), view: initView(db, i18n), report: initReport(db, i18n), - search: initSearch(db, i18n), + search: initSearch(db, i18n, conversation), role: initRole(db, i18n), tag: initTag(db, i18n), macro: initMacro(db, i18n), @@ -325,10 +336,13 @@ func main() { importer: initImporter(i18n), webhook: webhook, contextLink: initContextLink(db, i18n), + proactive: initProactive(db, i18n), rateLimit: rateLimiter, redis: rdb, fc: initFastCache(rdb), userNotification: userNotification, + notificationPref: notificationPreference, + pushNotification: pushNotification, wsHub: wsHub, } app.consts.Store(constants) diff --git a/cmd/media.go b/cmd/media.go index fb1234b0..b539d8c4 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -255,22 +255,19 @@ func serveMediaFile(r *fastglue.Request, app *App, uuid string, media *mmodels.M consts := app.consts.Load().(*constants) switch consts.UploadProvider { case "fs": - disposition := "attachment" - - // Inline images/videos/pdfs. SVG excluded. - if !forceDownload && - media.ContentType != "image/svg+xml" && - (strings.HasPrefix(media.ContentType, "image/") || - strings.HasPrefix(media.ContentType, "video/") || - media.ContentType == "application/pdf") { - disposition = "inline" + // Older rows hold content types exactly as the client sent them. + contentType := mmodels.NormalizeContentType(media.ContentType) + disposition := mmodels.ContentDisposition(contentType) + if forceDownload { + disposition = mmodels.DispositionAttachment } - r.RequestCtx.Response.Header.Set("Content-Type", media.ContentType) + r.RequestCtx.Response.Header.Set("Content-Type", contentType) r.RequestCtx.Response.Header.Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": media.Filename})) r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff") - // Sandbox SVGs. - if media.ContentType == "image/svg+xml" { + // Chrome's video and PDF viewers break in a sandbox, and inline types run no script anyway. + // Downloads are sandboxed for webviews that render attachments inline. + if disposition == mmodels.DispositionAttachment { r.RequestCtx.Response.Header.Set("Content-Security-Policy", "sandbox") } r.RequestCtx.Response.Header.Set("Cache-Control", fmt.Sprintf("%s, max-age=%d, immutable", cacheVisibility(media.Private), int(mediaCacheTTL.Seconds()))) diff --git a/cmd/media_test.go b/cmd/media_test.go index 3cc8f86a..b8234bff 100644 --- a/cmd/media_test.go +++ b/cmd/media_test.go @@ -3,7 +3,14 @@ package main import ( "bytes" "encoding/json" + "os" + "path/filepath" + "strings" "testing" + + mmodels "github.com/abhinavxd/libredesk/internal/media/models" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" ) func TestPrepareImageUploadAllowsOversizedImageWithoutThumbnail(t *testing.T) { @@ -31,3 +38,63 @@ func TestPrepareImageUploadAllowsOversizedImageWithoutThumbnail(t *testing.T) { t.Fatalf("dimensions = %dx%d, want 10000x5000", meta.Width, meta.Height) } } + +func TestServeMediaFileSandboxesDownloads(t *testing.T) { + const uuid = "0b7a3c1e-2f4d-4b8a-9c6e-1d2f3a4b5c6d" + dir := t.TempDir() + svg := []byte(``) + if err := os.WriteFile(filepath.Join(dir, uuid), svg, 0o600); err != nil { + t.Fatal(err) + } + if err := ko.Set("upload.fs.upload_path", dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ko.Delete("upload.fs.upload_path") }) + + app := &App{} + app.consts.Store(&constants{UploadProvider: "fs"}) + + tests := []struct { + name string + contentType string + download bool + wantType string + wantDisposition string + wantSandbox bool + }{ + {"svg", "image/svg+xml", false, "image/svg+xml", "attachment", true}, + {"svg with bad param", "image/svg+xml; x", false, "image/svg+xml", "attachment", true}, + {"other xml image", "image/x+xml", false, "image/x+xml", "attachment", true}, + {"unparseable", "text/plain,image/svg+xml", false, "application/octet-stream", "attachment", true}, + {"html", "text/html", false, "text/html", "attachment", true}, + {"png", "image/png", false, "image/png", "inline", false}, + {"png forced download", "image/png", true, "image/png", "attachment", true}, + {"video", "video/mp4", false, "video/mp4", "inline", false}, + {"video forced download", "video/mp4", true, "video/mp4", "attachment", true}, + {"pdf", "application/pdf", false, "application/pdf", "inline", false}, + {"pdf forced download", "application/pdf", true, "application/pdf", "attachment", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &fastglue.Request{RequestCtx: &fasthttp.RequestCtx{}, Context: app} + if tt.download { + req.RequestCtx.Request.SetRequestURI("/uploads/" + uuid + "?download=1") + } + media := &mmodels.Media{ContentType: tt.contentType, Filename: "file", Private: true} + if err := serveMediaFile(req, app, uuid, media); err != nil { + t.Fatal(err) + } + + h := &req.RequestCtx.Response.Header + if got := string(h.ContentType()); got != tt.wantType { + t.Fatalf("Content-Type = %q, want %q", got, tt.wantType) + } + if got := string(h.Peek("Content-Disposition")); !strings.HasPrefix(got, tt.wantDisposition) { + t.Fatalf("Content-Disposition = %q, want %s", got, tt.wantDisposition) + } + if got := string(h.Peek("Content-Security-Policy")) == "sandbox"; got != tt.wantSandbox { + t.Fatalf("sandboxed = %v, want %v", got, tt.wantSandbox) + } + }) + } +} diff --git a/cmd/pwa_test.go b/cmd/pwa_test.go new file mode 100644 index 00000000..65c1c7f9 --- /dev/null +++ b/cmd/pwa_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "strings" + "testing" + + "github.com/knadh/stuffbin" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" +) + +func TestServePWAFiles(t *testing.T) { + fs, err := stuffbin.NewLocalFS("/", + "../frontend/public/manifest.webmanifest:frontend/dist/main/manifest.webmanifest", + "../frontend/public/sw.js:frontend/dist/main/sw.js", + ) + if err != nil { + t.Fatalf("creating test filesystem: %v", err) + } + + tests := []struct { + name string + handler fastglue.FastRequestHandler + contentType string + wantServiceWorkerHdr bool + }{ + {"manifest", serveManifest, "application/manifest+json", false}, + {"service worker", serveServiceWorker, "application/javascript", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + req := &fastglue.Request{RequestCtx: ctx, Context: &App{fs: fs}} + if err := tc.handler(req); err != nil { + t.Fatalf("serving file: %v", err) + } + if got := string(ctx.Response.Header.ContentType()); got != tc.contentType { + t.Errorf("content type = %q, want %q", got, tc.contentType) + } + if got := string(ctx.Response.Header.Peek("Cache-Control")); got != "no-cache" { + t.Errorf("cache control = %q, want no-cache", got) + } + if tc.wantServiceWorkerHdr && string(ctx.Response.Header.Peek("Service-Worker-Allowed")) != "/" { + t.Error("service worker scope header is missing") + } + if strings.TrimSpace(string(ctx.Response.Body())) == "" { + t.Error("response body is empty") + } + }) + } +} diff --git a/cmd/search.go b/cmd/search.go index 50d9c83c..6793dd99 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -2,100 +2,168 @@ package main import ( "fmt" + "slices" + "strconv" amodels "github.com/abhinavxd/libredesk/internal/auth/models" + authzmodels "github.com/abhinavxd/libredesk/internal/authz/models" "github.com/abhinavxd/libredesk/internal/envelope" + searchmanager "github.com/abhinavxd/libredesk/internal/search" smodels "github.com/abhinavxd/libredesk/internal/search/models" "github.com/zerodha/fastglue" ) const ( minSearchQueryLength = 3 + + maxConversationSearchLimit = 1000 + maxMessageSearchLimit = 30 + maxContactSearchLimit = 15 ) -// handleSearchConversations searches conversations based on the query. +type searchPageResults struct { + Results any `json:"results"` + PerPage int `json:"per_page"` + HasMore bool `json:"has_more"` + NextCursor string `json:"next_cursor"` +} + func handleSearchConversations(r *fastglue.Request) error { - app, user, q, err := searchInputs(r) + app, user, term, err := searchTerm(r) if err != nil { return sendErrorEnvelope(r, err) } - results, err := app.search.Conversations(q) + scope, err := readScope(app, user.ID) if err != nil { return sendErrorEnvelope(r, err) } - uuids := make([]string, len(results)) - for i, c := range results { - uuids[i] = c.UUID - } - allowed, err := app.conversation.FilterAuthorizedListUUIDs(user.ID, uuids) - if err != nil { - return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) - } - set := uuidSet(allowed) - out := make([]smodels.ConversationResult, 0, len(allowed)) - for _, c := range results { - if _, ok := set[c.UUID]; ok { - out = append(out, c) - } - } - return r.SendEnvelope(out) -} - -// handleSearchMessages searches messages based on the query. -func handleSearchMessages(r *fastglue.Request) error { - app, user, q, err := searchInputs(r) - if err != nil { - return sendErrorEnvelope(r, err) - } - results, err := app.search.Messages(q) - if err != nil { - return sendErrorEnvelope(r, err) - } - uuids := make([]string, len(results)) - for i, m := range results { - uuids[i] = m.ConversationUUID - } - allowed, err := app.conversation.FilterAuthorizedListUUIDs(user.ID, uuids) - if err != nil { - return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) - } - set := uuidSet(allowed) - out := make([]smodels.MessageResult, 0, len(allowed)) - for _, m := range results { - if _, ok := set[m.ConversationUUID]; ok { - out = append(out, m) - } - } - return r.SendEnvelope(out) -} - -// handleSearchContacts searches contacts based on the query. -func handleSearchContacts(r *fastglue.Request) error { - app, _, q, err := searchInputs(r) - if err != nil { - return sendErrorEnvelope(r, err) - } - results, err := app.search.Contacts(q) + results, err := app.search.ConversationFirstPage(term, scope, searchLimit(r, maxConversationSearchLimit)) if err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(results) } -func searchInputs(r *fastglue.Request) (*App, amodels.User, string, error) { - app := r.Context.(*App) - user, _ := r.RequestCtx.UserValue("user").(amodels.User) - q := string(r.RequestCtx.QueryArgs().Peek("query")) - if len(q) < minSearchQueryLength { - return app, user, "", envelope.NewError(envelope.InputError, app.i18n.Ts("search.minQueryLength", "length", fmt.Sprintf("%d", minSearchQueryLength)), nil) +func handleSearchMessages(r *fastglue.Request) error { + app, user, term, err := searchTerm(r) + if err != nil { + return sendErrorEnvelope(r, err) } - return app, user, q, nil + scope, err := readScope(app, user.ID) + if err != nil { + return sendErrorEnvelope(r, err) + } + results, err := app.search.MessageFirstPage(term, scope, searchLimit(r, maxMessageSearchLimit)) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(results) } -func uuidSet(uuids []string) map[string]struct{} { - s := make(map[string]struct{}, len(uuids)) - for _, u := range uuids { - s[u] = struct{}{} +func handleSearchContacts(r *fastglue.Request) error { + app, _, term, err := searchTerm(r) + if err != nil { + return sendErrorEnvelope(r, err) } - return s + results, err := app.search.Contacts(term, searchLimit(r, maxContactSearchLimit)) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(results) +} + +func handlePaginatedSearchConversations(r *fastglue.Request) error { + app, user, q, err := searchInputs(r) + if err != nil { + return sendErrorEnvelope(r, err) + } + scope, err := readScope(app, user.ID) + if err != nil { + return sendErrorEnvelope(r, err) + } + results, hasMore, nextCursor, err := app.search.Conversations(q, scope) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(searchPageResults{ + Results: results, + PerPage: q.PageSize, + HasMore: hasMore, + NextCursor: nextCursor, + }) +} + +func handlePaginatedSearchMessages(r *fastglue.Request) error { + app, user, q, err := searchInputs(r) + if err != nil { + return sendErrorEnvelope(r, err) + } + scope, err := readScope(app, user.ID) + if err != nil { + return sendErrorEnvelope(r, err) + } + results, hasMore, nextCursor, err := app.search.Messages(q, scope) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(searchPageResults{ + Results: results, + PerPage: q.PageSize, + HasMore: hasMore, + NextCursor: nextCursor, + }) +} + +func searchInputs(r *fastglue.Request) (*App, amodels.User, smodels.Query, error) { + app, user, term, err := searchTerm(r) + if err != nil { + return app, user, smodels.Query{}, err + } + _, pageSize := getPagination(r) + query := searchmanager.NormalizeQuery(smodels.Query{ + Term: term, + Filters: string(r.RequestCtx.QueryArgs().Peek("filters")), + Cursor: string(r.RequestCtx.QueryArgs().Peek("cursor")), + Sort: smodels.Sort(r.RequestCtx.QueryArgs().Peek("sort")), + PageSize: pageSize, + }) + return app, user, query, nil +} + +func searchTerm(r *fastglue.Request) (*App, amodels.User, string, error) { + app := r.Context.(*App) + user, _ := r.RequestCtx.UserValue("user").(amodels.User) + term := string(r.RequestCtx.QueryArgs().Peek("query")) + if len(term) < minSearchQueryLength { + return app, user, "", envelope.NewError(envelope.InputError, app.i18n.Ts("search.minQueryLength", "length", fmt.Sprintf("%d", minSearchQueryLength)), nil) + } + return app, user, term, nil +} + +func searchLimit(r *fastglue.Request, max int) int { + limit, err := strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("limit"))) + if err != nil || limit < 1 || limit > max { + return max + } + return limit +} + +func readScope(app *App, agentID int) (smodels.ReadScope, error) { + agent, err := app.user.GetAgentCachedOrLoad(agentID) + if err != nil { + return smodels.ReadScope{}, err + } + if !agent.Enabled { + return smodels.ReadScope{}, nil + } + return smodels.ReadScope{ + UserID: agent.ID, + TeamIDs: agent.Teams.IDs(), + Read: slices.Contains(agent.Permissions, authzmodels.PermConversationsRead), + ReadAll: slices.Contains(agent.Permissions, authzmodels.PermConversationsReadAll), + ReadAssigned: slices.Contains(agent.Permissions, authzmodels.PermConversationsReadAssigned), + ReadTeamAll: slices.Contains(agent.Permissions, authzmodels.PermConversationsReadTeamAll), + ReadTeamInbox: slices.Contains(agent.Permissions, authzmodels.PermConversationsReadTeamInbox), + ReadUnassigned: slices.Contains(agent.Permissions, authzmodels.PermConversationsReadUnassigned), + }, nil } diff --git a/cmd/settings.go b/cmd/settings.go index 6cb48c8f..4f09c9ab 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/httputil" "github.com/abhinavxd/libredesk/internal/setting/models" "github.com/abhinavxd/libredesk/internal/stringutil" "github.com/valyala/fasthttp" @@ -57,6 +58,9 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error { } // Trim whitespace and trailing slash from root URL. req.RootURL = strings.TrimRight(strings.TrimSpace(req.RootURL), "/") + if !httputil.IsValidHTTPURL(req.RootURL) { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("admin.general.rootURL.valid"), nil, envelope.InputError) + } // Get current language before update. app.Lock() diff --git a/cmd/upgrade.go b/cmd/upgrade.go index f33f2f37..99e928e2 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -47,7 +47,8 @@ var migList = []migFunc{ {"v2.5.0", migrations.V2_5_0}, {"v2.6.0", migrations.V2_6_0}, {"v2.8.0", migrations.V2_8_0}, - {"v2.9.0", migrations.V2_9_0}, + {"v2.9.0-rc.8", migrations.V2_9_0}, + {"v2.9.0-rc.9", migrations.V2_9_0_RC9}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/cmd/user_notifications.go b/cmd/user_notifications.go index caadb242..3f39d96b 100644 --- a/cmd/user_notifications.go +++ b/cmd/user_notifications.go @@ -5,6 +5,8 @@ import ( amodels "github.com/abhinavxd/libredesk/internal/auth/models" "github.com/abhinavxd/libredesk/internal/envelope" + notifier "github.com/abhinavxd/libredesk/internal/notification" + nmodels "github.com/abhinavxd/libredesk/internal/notification/models" "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" ) @@ -97,3 +99,72 @@ func handleDeleteAllNotifications(r *fastglue.Request) error { } return r.SendEnvelope(true) } + +func handleGetNotificationPreferences(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + ) + prefs, err := app.notificationPref.GetMatrix(auser.ID) + if err != nil { + return sendErrorEnvelope(r, err) + } + pushEndpoints, err := app.pushNotification.Endpoints(auser.ID) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(map[string]any{ + "preferences": prefs, + "email_enabled": ko.Bool("notification.email.enabled"), + "vapid_public_key": app.pushNotification.PublicKey(), + "push_endpoints": pushEndpoints, + }) +} + +func handleUpdateNotificationPreferences(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + prefs []nmodels.NotificationPreference + ) + if err := r.Decode(&prefs, "json"); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, + app.i18n.T("errors.parsingRequest"), nil, envelope.InputError) + } + if err := app.notificationPref.Update(auser.ID, prefs); err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(true) +} + +func handleCreatePushSubscription(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + subscription notifier.PushSubscriptionInput + ) + if err := r.Decode(&subscription, "json"); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError) + } + if err := app.pushNotification.Upsert(auser.ID, subscription); err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(true) +} + +func handleDeletePushSubscription(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + request struct { + Endpoint string `json:"endpoint"` + } + ) + if err := r.Decode(&request, "json"); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError) + } + if err := app.pushNotification.Delete(auser.ID, request.Endpoint); err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(true) +} diff --git a/cmd/widget_campaign.go b/cmd/widget_campaign.go new file mode 100644 index 00000000..cdd44bc7 --- /dev/null +++ b/cmd/widget_campaign.go @@ -0,0 +1,295 @@ +package main + +import ( + "encoding/json" + "errors" + "slices" + "strconv" + "strings" + "time" + + bhmodels "github.com/abhinavxd/libredesk/internal/business_hours/models" + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat/proactive" + umodels "github.com/abhinavxd/libredesk/internal/user/models" + "github.com/google/uuid" + "github.com/zerodha/fastglue" +) + +type campaignEventRequest struct { + DeliveryID string `json:"delivery_id"` + BrowserKey string `json:"browser_key"` + Event string `json:"event"` +} + +type campaignCandidate struct { + campaign proactive.Campaign + snapshot proactive.Snapshot +} + +// handleWidgetCampaign is called by the widget as the visitor browses the site and returns the campaign message to pop up for this visitor right now, or nothing. +func handleWidgetCampaign(r *fastglue.Request) error { + app := r.Context.(*App) + var ctx proactive.Context + if err := r.Decode(&ctx, "json"); err != nil || !validCampaignKey(ctx.BrowserKey) || !validCampaignKey(ctx.SessionKey) || len(ctx.URL) > 4096 || ctx.ActiveSeconds < 0 || ctx.ActiveSeconds > 86400 { + return sendErrorEnvelope(r, campaignInputError(app)) + } + ctx.Now = time.Now() + ctx.Visitor = getWidgetIsVisitor(r) + contact, err := widgetContact(r) + if err != nil { + return sendErrorEnvelope(r, err) + } + ctx.ContactID = contact.ID + inbox, err := getWidgetInbox(r) + if err != nil { + return sendErrorEnvelope(r, err) + } + config, err := getWidgetConfig(r) + if err != nil { + return sendErrorEnvelope(r, err) + } + cooldown, err := parseCampaignCooldown(config.CampaignCooldown) + if err != nil { + return sendErrorEnvelope(r, campaignInputError(app)) + } + if err := checkConversationPermissions(app, config, ctx.Visitor, contact.ID, inbox.ID); err != nil { + return r.SendEnvelope(nil) + } + loc, err := time.LoadLocation(app.setting.GetAppTimezone()) + if err != nil { + return sendErrorEnvelope(r, campaignInputError(app)) + } + var candidates []campaignCandidate + for _, campaign := range config.Campaigns { + var hoursErr error + within := func() bool { + ok, err := campaignWithinHours(app, campaign, ctx.Now, loc) + hoursErr = err + return ok + } + reason := campaign.IneligibleReason(ctx, app.automation.MatchesContact(campaign.Conditions, contact), within) + if hoursErr != nil { + app.lo.Warn("skipping campaign with invalid business hours", "campaign_id", campaign.ID, "business_hours_id", campaign.BusinessHoursID) + continue + } + if reason != "" { + continue + } + sender, err := campaignSender(app, campaign.SenderID) + if err != nil { + continue + } + snapshot := proactive.Snapshot{Message: campaign.Message, Sender: strings.TrimSpace(sender.FullName()), Avatar: sender.AvatarURL.String, SenderID: sender.ID, TeamID: campaign.TeamID} + if campaign.SenderID == 0 { + snapshot.Sender = config.BrandName + } + candidates = append(candidates, campaignCandidate{campaign, snapshot}) + } + if len(candidates) == 0 { + return r.SendEnvelope(nil) + } + unlock := app.proactive.Lock() + defer unlock() + history, err := app.proactive.History(inbox.ID, ctx) + if err != nil { + return sendErrorEnvelope(r, err) + } + for _, c := range candidates { + if proactive.Suppression(c.campaign, ctx, history, cooldown) != "" { + continue + } + delivery, err := app.proactive.Reserve(inbox.ID, c.campaign, ctx, c.snapshot) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(delivery) + } + return r.SendEnvelope(nil) +} + +// handleWidgetCampaignEvent is called by the widget when a campaign message is shown, opened or closed, for the campaign stats. +func handleWidgetCampaignEvent(r *fastglue.Request) error { + app := r.Context.(*App) + var req campaignEventRequest + if err := r.Decode(&req, "json"); err != nil || !validCampaignKey(req.DeliveryID) || !validCampaignKey(req.BrowserKey) || !slices.Contains([]string{"displayed", "opened", "dismissed"}, req.Event) { + return sendErrorEnvelope(r, campaignInputError(app)) + } + inbox, err := getWidgetInbox(r) + if err != nil { + return sendErrorEnvelope(r, err) + } + contactID, _ := getWidgetContactID(r) + unlock := app.proactive.Lock() + defer unlock() + delivery, err := app.proactive.Get(req.DeliveryID, inbox.ID, req.BrowserKey, contactID) + if err != nil { + return sendErrorEnvelope(r, err) + } + if req.Event == "displayed" { + config, err := getWidgetConfig(r) + if err != nil { + return sendErrorEnvelope(r, err) + } + active := slices.ContainsFunc(config.Campaigns, func(c proactive.Campaign) bool { + return c.ID == delivery.CampaignID && c.Enabled + }) + if !active || delivery.Dismissed || delivery.Replied || time.Since(delivery.CreatedAt) > time.Minute { + return sendErrorEnvelope(r, envelope.NewError(envelope.ConflictError, app.i18n.T("widget.invitationExpired"), nil)) + } + } + if err := app.proactive.RecordEvent(delivery.ID, req.Event); err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(true) +} + +// handleCampaignStats returns shown, opened, dismissed and replied counts for each campaign in an inbox over a date range. +func handleCampaignStats(r *fastglue.Request) error { + app := r.Context.(*App) + id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + if err != nil || id <= 0 { + return sendErrorEnvelope(r, campaignInputError(app)) + } + loc, err := time.LoadLocation(app.setting.GetAppTimezone()) + if err != nil { + return sendErrorEnvelope(r, campaignInputError(app)) + } + from, err := time.ParseInLocation(time.DateOnly, string(r.RequestCtx.QueryArgs().Peek("from")), loc) + if err != nil { + return sendErrorEnvelope(r, campaignInputError(app)) + } + to, err := time.ParseInLocation(time.DateOnly, string(r.RequestCtx.QueryArgs().Peek("to")), loc) + if err != nil || to.Before(from) { + return sendErrorEnvelope(r, campaignInputError(app)) + } + stats, err := app.proactive.Stats(id, from, to.AddDate(0, 0, 1)) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(stats) +} + +func campaignWithinHours(app *App, c proactive.Campaign, now time.Time, loc *time.Location) (bool, error) { + if c.BusinessHours == "any" { + return true, nil + } + hours, err := app.businessHours.Get(c.BusinessHoursID) + if err != nil { + return false, envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil) + } + if hours.IsAlwaysOpen { + return true, nil + } + local := now.In(loc) + var holidays []bhmodels.Holiday + var working map[string]bhmodels.WorkingHours + if err := json.Unmarshal(hours.Holidays, &holidays); err != nil { + return false, campaignInputError(app) + } + if err := json.Unmarshal(hours.Hours, &working); err != nil { + return false, campaignInputError(app) + } + for _, holiday := range holidays { + if holiday.Date == local.Format(time.DateOnly) { + return false, nil + } + } + day, ok := working[local.Weekday().String()] + if !ok { + return false, nil + } + clock := local.Format("15:04") + return withinWorkingHours(clock, day), nil +} + +func withinWorkingHours(clock string, day bhmodels.WorkingHours) bool { + if day.Open == day.Close { + return false + } + if day.Open < day.Close { + return clock >= day.Open && clock < day.Close + } + return clock >= day.Open || clock < day.Close +} + +func campaignSender(app *App, id int) (umodels.User, error) { + if id == 0 { + return app.user.GetSystemUser() + } + return app.user.GetAgent(id, "") +} + +func earliestCampaignDelay(campaigns []proactive.Campaign) int { + delay, found := 0, false + for _, campaign := range campaigns { + if !campaign.Enabled { + continue + } + if !found || campaign.DelaySeconds < delay { + delay, found = campaign.DelaySeconds, true + } + } + return delay +} + +func validCampaignKey(key string) bool { + id, err := uuid.Parse(key) + return err == nil && id != uuid.Nil +} + +func campaignInputError(app *App) error { + return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidValue"), nil) +} + +func validateWidgetFeatures(app *App, config livechat.Config) error { + if config.Help.HelpCenterID < 0 || len(config.Help.FeaturedIDs) > 10 || len(config.Campaigns) > 50 { + return campaignInputError(app) + } + if _, err := parseCampaignCooldown(config.CampaignCooldown); err != nil { + return campaignInputError(app) + } + if config.Help.HelpCenterID > 0 { + if _, err := app.helpcenter.GetHelpCenterByID(config.Help.HelpCenterID); err != nil { + return err + } + } + loc, err := time.LoadLocation(app.setting.GetAppTimezone()) + if err != nil { + return campaignInputError(app) + } + ids := make(map[string]bool) + for _, campaign := range config.Campaigns { + if err := campaign.Validate(); err != nil { + return envelope.NewError(envelope.InputError, app.i18n.Ts("admin.inbox.livechat.campaignInvalid", "name", campaign.Name, "field", err.Error()), nil) + } + if ids[campaign.ID] { + return campaignInputError(app) + } + ids[campaign.ID] = true + if _, err := campaignSender(app, campaign.SenderID); err != nil { + return err + } + if campaign.TeamID > 0 { + if _, err := app.team.Get(campaign.TeamID); err != nil { + return err + } + } + if _, err := campaignWithinHours(app, campaign, time.Now(), loc); err != nil { + return err + } + } + return nil +} + +func parseCampaignCooldown(value string) (time.Duration, error) { + duration, err := time.ParseDuration(value) + if err != nil { + return 0, err + } + if duration < 0 { + return 0, errors.New("campaign cooldown cannot be negative") + } + return duration, nil +} diff --git a/cmd/widget_campaign_reply.go b/cmd/widget_campaign_reply.go new file mode 100644 index 00000000..0d513d1d --- /dev/null +++ b/cmd/widget_campaign_reply.go @@ -0,0 +1,109 @@ +package main + +import ( + 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" + realip "github.com/ferluci/fast-realip" + "github.com/zerodha/fastglue" +) + +// handleWidgetCampaignReply runs when a visitor replies to a campaign pop-up, adding the reply to its conversation or starting a new one. +func handleWidgetCampaignReply(r *fastglue.Request, req chatInitReq, inbox imodels.Inbox, config livechat.Config) error { + app := r.Context.(*App) + if !validCampaignKey(req.DeliveryID) || !validCampaignKey(req.BrowserKey) { + return sendErrorEnvelope(r, campaignInputError(app)) + } + unlock := app.proactive.Lock() + defer unlock() + contact, err := widgetContact(r) + if err != nil { + return sendErrorEnvelope(r, err) + } + delivery, err := app.proactive.Get(req.DeliveryID, inbox.ID, req.BrowserKey, contact.ID) + if err != nil { + return sendErrorEnvelope(r, err) + } + if delivery.Replied && delivery.ConversationUUID == "" { + return sendErrorEnvelope(r, envelope.NewError(envelope.NotFoundError, app.i18n.T("globals.messages.notFound"), nil)) + } + if !delivery.Displayed { + return sendErrorEnvelope(r, envelope.NewError(envelope.ConflictError, app.i18n.T("widget.invitationExpired"), nil)) + } + var sessionToken string + var attrs map[string]any + visitor := getWidgetIsVisitor(r) + if delivery.ConversationUUID != "" { + conversation, err := app.conversation.GetConversation(0, delivery.ConversationUUID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + if contact.ID != conversation.ContactID { + if contact.ID > 0 { + return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, app.i18n.T("widget.conversationNotPermitted"), nil)) + } + contact, err = app.user.GetContactOrVisitor(conversation.ContactID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + if contact.Type != "visitor" { + return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, app.i18n.T("widget.conversationNotPermitted"), nil)) + } + sessionToken, err = generateSessionToken(app, contact.ID, inbox.ID, true, "", defaultSessionTTL) + if err != nil { + return sendErrorEnvelope(r, err) + } + visitor = true + } + if err := canReply(r, conversation); err != nil { + return sendErrorEnvelope(r, err) + } + message := cmodels.Message{ConversationUUID: conversation.UUID, ConversationID: conversation.ID, SenderID: contact.ID, Type: cmodels.MessageIncoming, SenderType: cmodels.SenderTypeContact, Status: cmodels.MessageStatusReceived, Content: req.Message, ContentType: cmodels.ContentTypeText} + if _, err := app.conversation.ProcessIncomingLiveChatMessage(message); err != nil { + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.errorSendingMessage"), nil)) + } + } else { + if contact.ID == 0 { + contact, sessionToken, attrs, err = createVisitorContact(app, req.FormData, config, inbox) + if err != nil { + return sendErrorEnvelope(r, err) + } + visitor = true + } else { + attrs = saveContactAttrsAndCollectConvoAttrs(app, contact.ID, nil, req.FormData, config) + } + if err := checkConversationPermissions(app, config, visitor, contact.ID, inbox.ID); err != nil { + return sendErrorEnvelope(r, err) + } + meta := map[string]any{"ip": realip.FromRequest(r.RequestCtx), "user_agent": string(r.RequestCtx.Request.Header.Peek("User-Agent")), "campaign_id": delivery.CampaignID} + reply, err := app.conversation.CreateProactiveConversation(delivery, contact.ID, req.Message, attrs, meta, maxChatConversationsPerContact, chatConversationRateLimitWindow) + if err != nil { + if _, ok := err.(envelope.Error); ok { + return sendErrorEnvelope(r, err) + } + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.errorSendingMessage"), nil)) + } + delivery.ConversationUUID = reply.ConversationUUID + if reply.ID > 0 { + if err := app.conversation.ProcessIncomingMessageHooks(reply, true); err != nil { + app.lo.Error("error processing proactive reply hooks", "error", err) + } + } + } + conversation, err := app.conversation.GetConversation(0, delivery.ConversationUUID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + resp, err := buildConversationResponseWithBusinessHours(app, conversation) + if err != nil { + return sendErrorEnvelope(r, err) + } + response := map[string]any{"conversation": resp.Conversation, "messages": resp.Messages, "business_hours_id": resp.BusinessHoursID, "working_hours_utc_offset": resp.WorkingHoursUTCOffset} + if sessionToken != "" { + response["session_token"] = sessionToken + response["user"] = map[string]any{"user_id": contact.ID, "is_visitor": visitor, "first_name": contact.FirstName, "last_name": contact.LastName} + } + r.RequestCtx.Response.Header.Set("Cache-Control", "no-store") + return r.SendEnvelope(response) +} diff --git a/cmd/widget_campaign_test.go b/cmd/widget_campaign_test.go new file mode 100644 index 00000000..38c012ab --- /dev/null +++ b/cmd/widget_campaign_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "testing" + "time" + + bhmodels "github.com/abhinavxd/libredesk/internal/business_hours/models" +) + +func TestWithinWorkingHours(t *testing.T) { + tests := []struct { + name string + clock string + day bhmodels.WorkingHours + want bool + }{ + {name: "normal open", clock: "09:00", day: bhmodels.WorkingHours{Open: "09:00", Close: "17:00"}, want: true}, + {name: "normal close", clock: "17:00", day: bhmodels.WorkingHours{Open: "09:00", Close: "17:00"}}, + {name: "overnight evening", clock: "23:00", day: bhmodels.WorkingHours{Open: "22:00", Close: "02:00"}, want: true}, + {name: "overnight morning", clock: "01:00", day: bhmodels.WorkingHours{Open: "22:00", Close: "02:00"}, want: true}, + {name: "overnight closed", clock: "12:00", day: bhmodels.WorkingHours{Open: "22:00", Close: "02:00"}}, + {name: "same time closed", clock: "09:00", day: bhmodels.WorkingHours{Open: "09:00", Close: "09:00"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := withinWorkingHours(tc.clock, tc.day); got != tc.want { + t.Fatalf("got %v, want %v", got, tc.want) + } + }) + } +} + +func TestParseCampaignCooldown(t *testing.T) { + tests := []struct { + value string + want time.Duration + valid bool + }{ + {value: "0s", valid: true}, + {value: "10m", want: 10 * time.Minute, valid: true}, + {value: "1h30m", want: 90 * time.Minute, valid: true}, + {value: ""}, + {value: "tomorrow"}, + {value: "-1m"}, + } + + for _, tc := range tests { + t.Run(tc.value, func(t *testing.T) { + got, err := parseCampaignCooldown(tc.value) + if tc.valid && (err != nil || got != tc.want) { + t.Fatalf("got %v, %v; want %v", got, err, tc.want) + } + if !tc.valid && err == nil { + t.Fatalf("accepted %q", tc.value) + } + }) + } +} diff --git a/cmd/widget_help.go b/cmd/widget_help.go new file mode 100644 index 00000000..5c2dde83 --- /dev/null +++ b/cmd/widget_help.go @@ -0,0 +1,147 @@ +package main + +import ( + "slices" + "strings" + + "github.com/abhinavxd/libredesk/internal/envelope" + hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" + umodels "github.com/abhinavxd/libredesk/internal/user/models" + "github.com/zerodha/fastglue" +) + +func optionalWidgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) error { + return func(r *fastglue.Request) error { + if len(r.RequestCtx.Request.Header.Peek("Authorization")) == 0 { + return validateWidgetInbox(next)(r) + } + return widgetAuth(next)(r) + } +} + +func handleWidgetHelp(r *fastglue.Request) error { + app := r.Context.(*App) + helpCenter, audience, err := widgetHelpCenter(r) + if err != nil { + return sendErrorEnvelope(r, err) + } + if helpCenter.ID == 0 { + return r.SendEnvelope(nil) + } + locale := resolveQueryLocale(r, helpCenter) + tree, err := app.helpcenter.GetPublicTree(helpCenter, locale) + if err != nil { + return sendErrorEnvelope(r, err) + } + if len(tree.Tree) == 0 && locale != helpCenter.DefaultLocale { + locale = helpCenter.DefaultLocale + tree, err = app.helpcenter.GetPublicTree(helpCenter, locale) + if err != nil { + return sendErrorEnvelope(r, err) + } + } + hideTreeAuthors(helpCenterTheme(helpCenter), tree.Tree) + popular, err := app.helpcenter.GetPopularArticles(helpCenter.Slug, locale, 5) + if err != nil { + return sendErrorEnvelope(r, err) + } + for i := range popular { + hideArticleAuthor(helpCenterTheme(helpCenter), &popular[i]) + } + config, _ := getWidgetConfig(r) + return r.SendEnvelope(map[string]any{ + "slug": helpCenter.Slug, "name": helpCenter.Name, "locale": locale, + "url": helpCenterBaseURL(app, helpCenter) + helpCenterPathPrefix(helpCenter), + "tree": tree.Tree, "popular": popular, "audience": audience, "locales": helpCenterLocales(helpCenter), + "featured_ids": config.Help.FeaturedIDs, + }) +} + +func handleWidgetHelpSearch(r *fastglue.Request) error { + app := r.Context.(*App) + helpCenter, _, err := widgetHelpCenter(r) + if err != nil { + return sendErrorEnvelope(r, err) + } + if helpCenter.ID == 0 { + return r.SendEnvelope([]hcmodels.Article{}) + } + query := strings.TrimSpace(string(r.RequestCtx.QueryArgs().Peek("q"))) + if query == "" { + return r.SendEnvelope([]hcmodels.Article{}) + } + articles, err := app.helpcenter.SearchPublishedArticles(helpCenter.Slug, query, resolveQueryLocale(r, helpCenter), publicSearchLimit) + if err != nil { + return sendErrorEnvelope(r, err) + } + app.helpcenter.LogSearch(helpCenter.ID, query, len(articles)) + for i := range articles { + hideArticleAuthor(helpCenterTheme(helpCenter), &articles[i]) + } + return r.SendEnvelope(articles) +} + +func handleWidgetHelpArticle(r *fastglue.Request) error { + app := r.Context.(*App) + helpCenter, _, err := widgetHelpCenter(r) + if err != nil { + return sendErrorEnvelope(r, err) + } + if helpCenter.ID == 0 { + return sendErrorEnvelope(r, envelope.NewError(envelope.NotFoundError, app.i18n.T("globals.messages.notFound"), nil)) + } + slug := r.RequestCtx.UserValue("article_slug").(string) + locale := resolveQueryLocale(r, helpCenter) + article, err := app.helpcenter.GetPublishedArticle(helpCenter.Slug, slug, locale) + if envErr, ok := err.(envelope.Error); ok && envErr.ErrorType == envelope.NotFoundError && locale != helpCenter.DefaultLocale { + article, err = app.helpcenter.GetPublishedArticle(helpCenter.Slug, slug, helpCenter.DefaultLocale) + } + if err != nil { + return sendErrorEnvelope(r, err) + } + hideArticleAuthor(helpCenterTheme(helpCenter), &article) + translations, err := app.helpcenter.GetPublishedArticleTranslations(helpCenter.Slug, article.ID) + if err != nil { + return sendErrorEnvelope(r, err) + } + app.helpcenter.IncrementArticleViewCount(article.ID) + return r.SendEnvelope(helpArticleResponse{Article: article, Translations: translations}) +} + +func widgetHelpCenter(r *fastglue.Request) (hcmodels.HelpCenter, livechat.HelpAudience, error) { + app := r.Context.(*App) + config, err := getWidgetConfig(r) + if err != nil { + return hcmodels.HelpCenter{}, livechat.HelpAudience{}, err + } + audience := config.Help.Visitors + if !getWidgetIsVisitor(r) { + audience = config.Help.Users + } + if config.Help.HelpCenterID == 0 || (!audience.Tab && !hasHelpHomeApp(config)) { + return hcmodels.HelpCenter{}, audience, nil + } + helpCenter, err := app.helpcenter.GetHelpCenterByID(config.Help.HelpCenterID) + if err != nil { + return hcmodels.HelpCenter{}, audience, err + } + if !helpCenter.IsActive { + return hcmodels.HelpCenter{}, audience, nil + } + return helpCenter, audience, nil +} + +func widgetContact(r *fastglue.Request) (umodels.User, error) { + id, err := getWidgetContactID(r) + if err != nil { + return umodels.User{}, nil + } + return r.Context.(*App).user.GetContactOrVisitor(id, "") +} + +func hasHelpHomeApp(config livechat.Config) bool { + return slices.ContainsFunc(config.HomeApps, func(app livechat.HomeApp) bool { + return app.Type == livechat.HomeAppHelp + }) +} diff --git a/cmd/widget_transcript.go b/cmd/widget_transcript.go new file mode 100644 index 00000000..5c8e0bf1 --- /dev/null +++ b/cmd/widget_transcript.go @@ -0,0 +1,42 @@ +package main + +import ( + "fmt" + "mime" + "time" + + cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/stringutil" + "github.com/zerodha/fastglue" +) + +const maxTranscriptDownloadMessages = 1000 + +func handleWidgetTranscript(r *fastglue.Request) error { + app := r.Context.(*App) + config, err := getWidgetConfig(r) + if err != nil { + return sendErrorEnvelope(r, err) + } + if !config.Features.Transcript { + return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, app.i18n.T("status.deniedPermission"), nil)) + } + uuid := r.RequestCtx.UserValue("uuid").(string) + _, conversation, err := getContactConversation(r, uuid) + if err != nil { + return sendErrorEnvelope(r, err) + } + private := false + messages, err := app.conversation.GetAllConversationMessages(uuid, &private, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing}, maxTranscriptDownloadMessages) + if err != nil { + return sendErrorEnvelope(r, err) + } + filename := fmt.Sprintf("transcript-%s.txt", stringutil.SanitizeFilename(conversation.ReferenceNumber)) + r.RequestCtx.Response.Header.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) + r.RequestCtx.Response.Header.Set("Cache-Control", "private, no-store") + r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff") + r.RequestCtx.SetContentType("text/plain; charset=utf-8") + r.RequestCtx.SetBody(app.conversation.BuildTranscript(conversation, messages, time.Now())) + return nil +} diff --git a/config.sample.toml b/config.sample.toml index 50d5bcc7..f09ec80c 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -34,6 +34,13 @@ read_buffer_size = 65536 # Keepalive settings. keepalive_timeout = "10s" +[auth] +# Allow users to sign in with their libredesk email and password. +# Disable this only after configuring and enabling at least one OIDC provider. +# libredesk automatically keeps local login available when no OIDC provider is enabled to prevent lockout. +# If an enabled provider is unavailable or misconfigured, set auth.local_login_enabled = true to restore password login. +local_login_enabled = true + # File upload provider to use, either `fs` or `s3`. [upload] provider = "fs" diff --git a/frontend/apps/main/index.html b/frontend/apps/main/index.html index 781f17db..1ce7e812 100644 --- a/frontend/apps/main/index.html +++ b/frontend/apps/main/index.html @@ -5,8 +5,11 @@ + + + - + @@ -14,4 +17,4 @@ - \ No newline at end of file + diff --git a/frontend/apps/main/src/App.vue b/frontend/apps/main/src/App.vue index fd899254..b9dfe330 100644 --- a/frontend/apps/main/src/App.vue +++ b/frontend/apps/main/src/App.vue @@ -35,12 +35,12 @@
@@ -63,7 +63,13 @@ - + + + diff --git a/frontend/apps/main/src/components/datatable/DataTable.vue b/frontend/apps/main/src/components/datatable/DataTable.vue index 5c7e8ee3..13d21aed 100644 --- a/frontend/apps/main/src/components/datatable/DataTable.vue +++ b/frontend/apps/main/src/components/datatable/DataTable.vue @@ -1,20 +1,19 @@