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.
-
+
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 @@
+
+
+
-
+