package widget import ( "context" "errors" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/internal/webhookutil" ) // WidgetHandler handles the public-facing widget API endpoints. // These endpoints are accessed by the embedded JS widget on customer websites // and do not require agent JWT authentication — they use a widget_token instead. // Reference: Chatwoot app/controllers/api/v1/widget_messages_controller.rb type WidgetHandler struct { widgetService *service.WidgetService } // NewHandler creates a new WidgetHandler with the WidgetService dependency. func NewHandler(widgetService *service.WidgetService) *WidgetHandler { return &WidgetHandler{ widgetService: widgetService, } } // AllowIframeRequests applies Chatwoot's WidgetsController iframe policy only // to the widget HTML response. func (h *WidgetHandler) AllowIframeRequests(c *gin.Context) { inbox, err := h.widgetService.GetInboxByWebsiteToken(c.Request.Context(), strings.TrimSpace(c.Query("website_token"))) if err != nil { c.Status(http.StatusNotFound) c.Abort() return } config, err := service.ParseWebWidgetConfig(inbox.ChannelConfig) if err != nil { c.Status(http.StatusInternalServerError) c.Abort() return } frameAncestors, ok := widgetFrameAncestors(config.AllowedDomains) if !ok { c.Status(http.StatusInternalServerError) c.Abort() return } c.Writer.Header().Del("X-Frame-Options") c.Header("Content-Security-Policy", widgetContentSecurityPolicy(c.Writer.Header().Get("Content-Security-Policy"), frameAncestors)) } func widgetFrameAncestors(allowedDomains string) (string, bool) { domains := make([]string, 0) for _, domain := range strings.Split(allowedDomains, ",") { domain = strings.TrimSpace(domain) if domain == "" { continue } if strings.ContainsAny(domain, "; \t\r\n") { return "", false } domains = append(domains, domain) } return strings.Join(domains, " "), true } func widgetContentSecurityPolicy(policy, frameAncestors string) string { directives := strings.Split(policy, ";") result := make([]string, 0, len(directives)) found := false for _, directive := range directives { directive = strings.TrimSpace(directive) if strings.HasPrefix(directive, "frame-ancestors ") { found = true if frameAncestors != "" { result = append(result, "frame-ancestors "+frameAncestors) } continue } if directive != "" { result = append(result, directive) } } if frameAncestors != "" && !found { result = append(result, "frame-ancestors "+frameAncestors) } return strings.Join(result, "; ") } // Init handles widget initialization — authenticates/creates a contact // and returns a widget_token (pubsub_token) for subsequent requests. // POST /widget/init // Reference: Chatwoot widget SDK init — website_token identifies the inbox, // contact attributes are optional (anonymous visitor if not provided). func (h *WidgetHandler) Init(c *gin.Context) { var req service.WidgetInitRequest if err := c.ShouldBindJSON(&req); err != nil && c.Request.ContentLength != 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } if req.WebsiteToken == "" { req.WebsiteToken = c.Query("website_token") } if req.WidgetToken == "" { req.WidgetToken = widgetTokenFromRequest(c) } // HMAC verification: if the client provides an identifier + identifier_hash, // verify the hash against the inbox's hmac_token. This mirrors Chatwoot's // WebWidget HMAC verification (identifier_hash is SHA-256 HMAC of the identifier // using the hmac_token from channel_config). // When HMAC is verified, the contact is treated as authenticated (not anonymous). identifierHash := c.GetHeader("X-Identifier-Hash") if identifierHash == "" { identifierHash = c.Query("identifier_hash") } if req.Identifier != "" && identifierHash != "" { // Resolve the inbox to get its hmac_token for verification inbox, err := h.widgetService.GetInboxByWebsiteToken(c.Request.Context(), req.WebsiteToken) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid website_token for HMAC verification"}) return } widgetConfig, err := service.ParseWebWidgetConfig(inbox.ChannelConfig) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid widget config"}) return } if service.VerifyHMAC(widgetConfig.HMACToken, req.Identifier, identifierHash) { req.HMACVerified = true } } resp, err := h.widgetService.Init(c.Request.Context(), req) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, resp) } // Config implements Chatwoot's POST /api/v1/widget/config endpoint. func (h *WidgetHandler) Config(c *gin.Context) { var req service.WidgetInitRequest if err := c.ShouldBindJSON(&req); err != nil && c.Request.ContentLength != 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } if req.WebsiteToken == "" { req.WebsiteToken = c.Query("website_token") } if req.WidgetToken == "" { req.WidgetToken = widgetTokenFromRequest(c) } resp, err := h.widgetService.Init(c.Request.Context(), req) if err != nil { if widgetWebsiteTokenNotFoundLike(err) { c.Status(http.StatusNotFound) return } if strings.Contains(err.Error(), "Account is suspended") { c.JSON(http.StatusUnauthorized, gin.H{"error": "Account is suspended"}) return } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } contact := gin.H{ "id": resp.ContactID, "pubsub_token": resp.WidgetToken, } if resp.Contact != nil { contact["email"] = resp.Contact.Email contact["identifier"] = resp.Contact.Identifier contact["name"] = resp.Contact.Name contact["phone_number"] = resp.Contact.PhoneNumber } channelConfig := gin.H{ "auth_token": resp.WidgetToken, "website_token": resp.WidgetConfig.WebsiteToken, "widget_color": resp.WidgetConfig.WidgetColor, "welcome_title": resp.WidgetConfig.WelcomeTitle, "welcome_tagline": resp.WidgetConfig.WelcomeTagline, "website_name": resp.InboxName, "enabledFeatures": widgetEnabledFeatures(resp.WidgetConfig), } if resp.WidgetConfig.PreChatFieldsEnabled { channelConfig["preChatFormEnabled"] = true channelConfig["preChatFormOptions"] = gin.H{ "pre_chat_message": resp.WidgetConfig.PreChatMessage, "pre_chat_fields": defaultWidgetPreChatFields(), } } else { channelConfig["preChatFormEnabled"] = false channelConfig["preChatFormOptions"] = gin.H{"pre_chat_message": "", "pre_chat_fields": []gin.H{}} } c.JSON(http.StatusOK, gin.H{ "website_channel_config": channelConfig, "contact": contact, "global_config": gin.H{ "directUploadsEnabled": true, "maximumFileUploadSize": 40, }, }) } func defaultWidgetPreChatFields() []gin.H { return []gin.H{ {"label": "Email Id", "name": "emailAddress", "type": "email", "field_type": "standard", "required": false, "enabled": true}, {"label": "Full name", "name": "fullName", "type": "text", "field_type": "standard", "required": true, "enabled": true}, } } func widgetEnabledFeatures(config service.WebWidgetConfig) []string { if len(config.SelectedFeatureFlags) > 0 { return config.SelectedFeatureFlags } return []string{"attachments", "emoji_picker", "end_conversation"} } // SendMessage sends a message from the widget contact to the conversation. // POST /widget/messages // Reference: Chatwoot widget SDK — send message endpoint func (h *WidgetHandler) SendMessage(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } req, err := bindWidgetSendMessageRequest(c) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } req.WidgetToken = widgetToken resp, err := h.widgetService.SendMessage(c.Request.Context(), req) if err != nil { if errors.Is(err, service.ErrWidgetMessageContentTooLong) && c.FullPath() != "/widget/messages" { c.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error()}) return } status := http.StatusBadRequest if err.Error() == "invalid widget_token" || err.Error() == "widget_token required" { status = http.StatusUnauthorized } c.JSON(status, gin.H{"error": err.Error()}) return } conversationID := resp.ConversationID if conversation, lookupErr := h.widgetService.GetConversation(c.Request.Context(), widgetToken, resp.ConversationID); lookupErr == nil { conversationID = widgetConversationID(*conversation) } payload := widgetMessagePayload(resp.Message, conversationID) if len(resp.Attachments) > 0 { payload["attachments"] = widgetAttachmentPayloads(resp.Attachments) } c.JSON(http.StatusOK, payload) } func (h *WidgetHandler) UpdateMessage(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } messageID, err := strconv.ParseUint(c.Param("message_id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"}) return } var req struct { Contact struct { Email string `json:"email"` Name string `json:"name"` } `json:"contact"` Message struct { SubmittedValues []map[string]any `json:"submitted_values"` } `json:"message"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } contact, _, err := h.widgetService.UpdateMessage(c.Request.Context(), service.WidgetMessageUpdate{ MessageID: uint(messageID), WidgetToken: widgetToken, ContactEmail: req.Contact.Email, ContactName: req.Contact.Name, SubmittedValues: req.Message.SubmittedValues, }) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"contact": widgetContactFullPayload(contact)}) } // GetLatestMessages implements Chatwoot's GET /api/v1/widget/messages endpoint. func (h *WidgetHandler) GetLatestMessages(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } after, _ := strconv.ParseUint(c.DefaultQuery("after", "0"), 10, 64) before, _ := strconv.ParseUint(c.DefaultQuery("before", "0"), 10, 64) messages, total, conversation, err := h.widgetService.GetLatestConversationMessages(c.Request.Context(), widgetToken, uint(after), uint(before)) if err != nil { status := http.StatusBadRequest if err.Error() == "invalid widget_token" { status = http.StatusUnauthorized } c.JSON(status, gin.H{"error": err.Error()}) return } payload := make([]gin.H, 0, len(messages)) conversationID := uint(0) if conversation != nil { conversationID = widgetConversationID(*conversation) } for _, msg := range messages { messagePayload := widgetMessagePayload(msg, conversationID) if attachments, err := h.widgetService.GetMessageAttachments(c.Request.Context(), msg.ID); err == nil && len(attachments) > 0 { messagePayload["attachments"] = widgetAttachmentPayloads(attachments) } payload = append(payload, messagePayload) } meta := gin.H{"total": total} if conversation != nil && conversation.ContactLastSeenAt != nil { meta["contact_last_seen_at"] = *conversation.ContactLastSeenAt } c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": meta}) } // GetConversations returns the conversation list for a widget contact. // GET /widget/conversations // Reference: Chatwoot widget SDK — fetch conversation list func (h *WidgetHandler) GetConversations(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } conversations, err := h.widgetService.GetConversations(c.Request.Context(), widgetToken) if err != nil { status := http.StatusBadRequest if err.Error() == "invalid widget_token" { status = http.StatusUnauthorized } c.JSON(status, gin.H{"error": err.Error()}) return } if c.FullPath() == "/api/v1/widget/conversations" { if len(conversations) == 0 { c.JSON(http.StatusOK, gin.H{}) return } conversation := conversations[0] c.JSON(http.StatusOK, widgetConversationPayload(conversation)) return } c.JSON(http.StatusOK, gin.H{"conversations": conversations}) } // CreateConversation implements Chatwoot's POST /api/v1/widget/conversations endpoint. func (h *WidgetHandler) CreateConversation(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } req, err := bindWidgetSendMessageRequest(c) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } req.WidgetToken = widgetToken req.ConversationID = nil resp, err := h.widgetService.SendMessage(c.Request.Context(), req) if err != nil { status := http.StatusBadRequest if err.Error() == "invalid widget_token" || err.Error() == "widget_token required" { status = http.StatusUnauthorized } c.JSON(status, gin.H{"error": err.Error()}) return } conversation, err := h.widgetService.GetConversation(c.Request.Context(), widgetToken, resp.ConversationID) if err != nil { c.JSON(http.StatusOK, gin.H{"id": resp.ConversationID, "messages": []gin.H{widgetMessagePayload(resp.Message, resp.ConversationID)}}) return } payload := widgetConversationPayload(*conversation) payload["messages"] = []gin.H{widgetMessagePayload(resp.Message, widgetConversationID(*conversation))} c.JSON(http.StatusOK, payload) } // GetMessages retrieves messages for a specific conversation. // GET /widget/conversations/:id/messages // Reference: Chatwoot widget SDK — fetch messages for a conversation func (h *WidgetHandler) GetMessages(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } conversationIDStr := c.Param("id") conversationID, err := strconv.ParseUint(conversationIDStr, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid conversation id"}) return } offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "25")) if limit <= 0 || limit > 100 { limit = 25 } messages, total, err := h.widgetService.GetMessages( c.Request.Context(), widgetToken, uint(conversationID), offset, limit) if err != nil { status := http.StatusBadRequest if err.Error() == "invalid widget_token" { status = http.StatusUnauthorized } c.JSON(status, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "messages": messages, "meta": gin.H{ "total": total, "offset": offset, "limit": limit, }, }) } // GetCableToken returns the pubsub_token for WebSocket/ActionCable connection. // GET /widget/cable_token // Reference: Chatwoot widget SDK — fetch token for ActionCable subscription func (h *WidgetHandler) GetCableToken(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } resp, err := h.widgetService.GetCableToken(c.Request.Context(), widgetToken) if err != nil { status := http.StatusBadRequest if err.Error() == "invalid widget_token" { status = http.StatusUnauthorized } c.JSON(status, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, resp) } // UpdateContact updates the contact's profile from the widget. // PATCH /widget/contact // Reference: Chatwoot widget SDK — update contact name/email func (h *WidgetHandler) UpdateContact(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { if isChatwootWidgetRoute(c) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } var req struct { Name string `json:"name,omitempty"` Email string `json:"email,omitempty"` PhoneNumber string `json:"phone_number,omitempty"` Identifier string `json:"identifier,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` CustomAttributes map[string]any `json:"custom_attributes,omitempty"` AdditionalAttributes map[string]any `json:"additional_attributes,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } contact, err := h.widgetService.UpdateContactProfile(c.Request.Context(), widgetToken, service.WidgetContactUpdate{ Name: req.Name, Email: req.Email, PhoneNumber: req.PhoneNumber, Identifier: req.Identifier, AvatarURL: req.AvatarURL, CustomAttributes: req.CustomAttributes, AdditionalAttributes: req.AdditionalAttributes, }) if err != nil { if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { c.Status(http.StatusNotFound) return } status := http.StatusBadRequest if err.Error() == "invalid widget_token" { status = http.StatusUnauthorized } c.JSON(status, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, widgetContactPayload(contact)) } // GetContact implements Chatwoot's GET /api/v1/widget/contact endpoint. func (h *WidgetHandler) GetContact(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { if isChatwootWidgetRoute(c) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } contact, err := h.widgetService.GetContact(c.Request.Context(), widgetToken) if err != nil { if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { c.Status(http.StatusNotFound) return } status := http.StatusBadRequest if err.Error() == "invalid widget_token" { status = http.StatusUnauthorized } c.JSON(status, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, widgetContactPayload(contact)) } func (h *WidgetHandler) DestroyContactCustomAttributes(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { if isChatwootWidgetRoute(c) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } var req struct { CustomAttributes []string `json:"custom_attributes"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } contact, err := h.widgetService.DeleteContactCustomAttributes(c.Request.Context(), widgetToken, req.CustomAttributes) if err != nil { if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, contact) } func (h *WidgetHandler) SetUser(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } var req struct { Identifier string `json:"identifier"` IdentifierHash string `json:"identifier_hash"` Email string `json:"email"` Name string `json:"name"` AvatarURL string `json:"avatar_url"` PhoneNumber string `json:"phone_number"` CustomAttributes map[string]any `json:"custom_attributes"` AdditionalAttributes map[string]any `json:"additional_attributes"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } identifierHash := req.IdentifierHash if identifierHash == "" { identifierHash = c.Query("identifier_hash") } resp, err := h.widgetService.SetUser(c.Request.Context(), service.WidgetSetUserRequest{ WebsiteToken: c.Query("website_token"), WidgetToken: widgetToken, Identifier: req.Identifier, IdentifierHash: identifierHash, Email: req.Email, Name: req.Name, AvatarURL: req.AvatarURL, PhoneNumber: req.PhoneNumber, CustomAttributes: req.CustomAttributes, AdditionalAttributes: req.AdditionalAttributes, }) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } payload := widgetContactPayload(resp.Contact) if resp.WidgetAuthToken != "" { payload["widget_auth_token"] = resp.WidgetAuthToken } c.JSON(http.StatusOK, payload) } // ToggleTyping signals that the contact is typing or stopped typing. // POST /widget/conversations/:id/toggle_typing // Reference: Chatwoot widget SDK — typing indicator func (h *WidgetHandler) ToggleTyping(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } var conversationID uint64 var err error conversationIDStr := c.Param("id") if conversationIDStr != "" { conversationID, err = strconv.ParseUint(conversationIDStr, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid conversation id"}) return } } else { conversation, convErr := h.widgetService.GetLatestConversation(c.Request.Context(), widgetToken) if convErr != nil { c.JSON(http.StatusNotFound, gin.H{"error": convErr.Error()}) return } conversationID = uint64(conversation.ID) } var req struct { Typing bool `json:"typing"` TypingStatus string `json:"typing_status"` } if err := c.ShouldBindJSON(&req); err != nil { // Default to typing=true if no body provided req.Typing = true } if req.TypingStatus == "on" { req.Typing = true } else if req.TypingStatus == "off" { req.Typing = false } err = h.widgetService.ToggleTyping(c.Request.Context(), widgetToken, uint(conversationID), req.Typing) if err != nil { status := http.StatusBadRequest if err.Error() == "invalid widget_token" { status = http.StatusUnauthorized } c.JSON(status, gin.H{"error": err.Error()}) return } c.Status(http.StatusOK) } func (h *WidgetHandler) UpdateLastSeen(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { if isChatwootWidgetRoute(c) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } _, err := h.widgetService.UpdateLastSeen(c.Request.Context(), widgetToken) if err != nil { if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.Status(http.StatusOK) } func (h *WidgetHandler) ToggleStatus(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } _, err := h.widgetService.ResolveLatestConversation(c.Request.Context(), widgetToken) if err != nil { if errors.Is(err, service.ErrWidgetEndConversationDisabled) { c.Status(http.StatusForbidden) return } if errors.Is(err, service.ErrWidgetConversationNotFound) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.Status(http.StatusOK) } func (h *WidgetHandler) SetConversationCustomAttributes(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { if isChatwootWidgetRoute(c) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } var req struct { CustomAttributes map[string]any `json:"custom_attributes"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } _, err := h.widgetService.SetLatestConversationCustomAttributes(c.Request.Context(), widgetToken, req.CustomAttributes) if err != nil { if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.Status(http.StatusOK) } func (h *WidgetHandler) DestroyConversationCustomAttributes(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { if isChatwootWidgetRoute(c) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } var req struct { CustomAttribute []string `json:"custom_attribute"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } conversation, err := h.widgetService.DeleteLatestConversationCustomAttributes(c.Request.Context(), widgetToken, req.CustomAttribute) if err != nil { if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, conversation) } func (h *WidgetHandler) ListInboxMembers(c *gin.Context) { websiteToken := c.Query("website_token") if websiteToken == "" { if isChatwootWidgetRoute(c) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusBadRequest, gin.H{"error": "website_token is required"}) return } members, err := h.widgetService.GetInboxMembersByWebsiteToken(c.Request.Context(), websiteToken) if err != nil { if isChatwootWidgetRoute(c) && widgetWebsiteTokenNotFoundLike(err) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"payload": members}) } func (h *WidgetHandler) ListCampaigns(c *gin.Context) { websiteToken := c.Query("website_token") if websiteToken == "" { if isChatwootWidgetRoute(c) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusBadRequest, gin.H{"error": "website_token is required"}) return } campaigns, err := h.widgetService.GetCampaignsByWebsiteToken(c.Request.Context(), websiteToken) if err != nil { if isChatwootWidgetRoute(c) && widgetWebsiteTokenNotFoundLike(err) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, campaigns) } func (h *WidgetHandler) CreateEvent(c *gin.Context) { var req struct { Name string `json:"name" form:"name"` WebsiteToken string `json:"website_token" form:"website_token"` EventInfo map[string]any `json:"event_info" form:"event_info"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } websiteToken := strings.TrimSpace(c.Query("website_token")) if websiteToken == "" { websiteToken = strings.TrimSpace(req.WebsiteToken) } if err := h.widgetService.TrackEvent(c.Request.Context(), websiteToken, widgetTokenFromRequest(c), req.Name, req.EventInfo); err != nil { if isChatwootWidgetRoute(c) && widgetWebsiteTokenNotFoundLike(err) { c.Status(http.StatusNotFound) return } if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.Status(http.StatusNoContent) } func (h *WidgetHandler) AddLabel(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { if isChatwootWidgetRoute(c) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } var req struct { Label string `json:"label"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } if err := h.widgetService.AddLabelToLatestConversation(c.Request.Context(), widgetToken, req.Label); err != nil { if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.Status(http.StatusNoContent) } func (h *WidgetHandler) RemoveLabel(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { if isChatwootWidgetRoute(c) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } if err := h.widgetService.RemoveLabelFromLatestConversation(c.Request.Context(), widgetToken, c.Param("label_id")); err != nil { if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { c.Status(http.StatusNotFound) return } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.Status(http.StatusNoContent) } func (h *WidgetHandler) SendTranscript(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } if err := h.widgetService.SendTranscript(c.Request.Context(), widgetToken); err != nil { if errors.Is(err, service.ErrWidgetConversationNotFound) { c.Status(http.StatusTooManyRequests) return } if errors.Is(err, service.ErrEmailTranscriptDisabled) { c.Status(http.StatusPaymentRequired) return } if errors.Is(err, service.ErrEmailRateLimited) { c.Status(http.StatusTooManyRequests) return } c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } c.Status(http.StatusOK) } func (h *WidgetHandler) AddDyteParticipantToMeeting(c *gin.Context) { var req struct { MessageID uint `json:"message_id"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } resp, err := h.widgetService.AddDyteParticipant(c.Request.Context(), c.Query("website_token"), widgetTokenFromRequest(c), req.MessageID) if err != nil { status := http.StatusUnprocessableEntity if strings.Contains(err.Error(), "website_token") || strings.Contains(err.Error(), "widget_token") { status = http.StatusBadRequest } c.JSON(status, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, resp) } func (h *WidgetHandler) PublicInboxShow(c *gin.Context) { inbox, identityValidation, err := h.widgetService.PublicGetInbox(c.Request.Context(), c.Param("inbox_id")) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } payload := publicInboxPayload(inbox) payload["identifier"] = c.Param("inbox_id") payload["identity_validation_enabled"] = identityValidation c.JSON(http.StatusOK, payload) } // Public widget requests are not a trusted connector boundary. Origin headers // are never promoted into internal event context here. func (h *WidgetHandler) PublicCreateContact(c *gin.Context) { req, err := bindPublicContactRequest(c) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } resp, err := h.widgetService.PublicCreateContact(c.Request.Context(), c.Param("inbox_id"), req) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, publicContactPayload(resp.ContactInbox, resp.Contact)) } func (h *WidgetHandler) PublicGetContact(c *gin.Context) { resp, err := h.widgetService.PublicGetContact(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id")) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, publicContactPayload(resp.ContactInbox, resp.Contact)) } func (h *WidgetHandler) PublicUpdateContact(c *gin.Context) { req, err := bindPublicContactRequest(c) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } resp, err := h.widgetService.PublicUpdateContact(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), req) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, publicContactPayload(resp.ContactInbox, resp.Contact)) } func (h *WidgetHandler) PublicListConversations(c *gin.Context) { conversations, err := h.widgetService.PublicListConversations(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id")) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } payload := make([]gin.H, 0, len(conversations)) for _, conversation := range conversations { messages, _, _, err := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), publicDisplayID(conversation), service.PublicMessageListOptions{}) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } payload = append(payload, h.publicConversationPayload(c.Request.Context(), conversation, messages)) } c.JSON(http.StatusOK, payload) } func (h *WidgetHandler) PublicCreateConversation(c *gin.Context) { var req struct { CustomAttributes map[string]any `json:"custom_attributes"` } if err := c.ShouldBindJSON(&req); err != nil && c.Request.ContentLength != 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } conversation, err := h.widgetService.PublicCreateConversation(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), service.PublicConversationRequest{CustomAttributes: req.CustomAttributes}) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, h.publicConversationPayload(c.Request.Context(), *conversation, nil)) } func (h *WidgetHandler) PublicGetConversation(c *gin.Context) { conversationID, ok := publicUintParam(c, "conversation_id") if !ok { return } conversation, err := h.widgetService.PublicGetConversation(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } messages, _, _, _ := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, service.PublicMessageListOptions{}) c.JSON(http.StatusOK, h.publicConversationPayload(c.Request.Context(), *conversation, messages)) } func (h *WidgetHandler) PublicToggleStatus(c *gin.Context) { conversationID, ok := publicUintParam(c, "conversation_id") if !ok { return } conversation, err := h.widgetService.PublicToggleStatus(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, h.publicConversationPayload(c.Request.Context(), *conversation, nil)) } func (h *WidgetHandler) PublicToggleTyping(c *gin.Context) { conversationID, ok := publicUintParam(c, "conversation_id") if !ok { return } var req struct { TypingStatus string `json:"typing_status"` } _ = c.ShouldBindJSON(&req) if req.TypingStatus != "on" && req.TypingStatus != "off" { c.Status(http.StatusOK) return } if err := h.widgetService.PublicToggleTyping(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, req.TypingStatus != "off"); err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } c.Status(http.StatusOK) } func (h *WidgetHandler) PublicUpdateLastSeen(c *gin.Context) { conversationID, ok := publicUintParam(c, "conversation_id") if !ok { return } if _, err := h.widgetService.PublicUpdateLastSeen(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID); err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } c.Status(http.StatusOK) } func (h *WidgetHandler) PublicListMessages(c *gin.Context) { conversationID, ok := publicUintParam(c, "conversation_id") if !ok { return } offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "0")) before, _ := strconv.ParseUint(c.Query("before"), 10, 64) messages, _, conversation, err := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, service.PublicMessageListOptions{Before: uint(before), Offset: offset, Limit: limit}) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } payload := make([]gin.H, 0, len(messages)) for _, message := range messages { messagePayload := publicMessagePayload(message, *conversation) if attachments, err := h.widgetService.GetMessageAttachments(c.Request.Context(), message.ID); err == nil && len(attachments) > 0 { messagePayload["attachments"] = widgetAttachmentPayloads(attachments) } payload = append(payload, messagePayload) } c.JSON(http.StatusOK, payload) } func (h *WidgetHandler) PublicCreateMessage(c *gin.Context) { conversationID, ok := publicUintParam(c, "conversation_id") if !ok { return } req, err := bindPublicMessageRequest(c) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } message, conversation, attachments, err := h.widgetService.PublicCreateMessage(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, req) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } payload := publicMessagePayload(*message, *conversation) if len(attachments) > 0 { payload["attachments"] = widgetAttachmentPayloads(attachments) } c.JSON(http.StatusOK, payload) } func (h *WidgetHandler) PublicUpdateMessage(c *gin.Context) { conversationID, ok := publicUintParam(c, "conversation_id") if !ok { return } messageID, ok := publicUintParam(c, "message_id") if !ok { return } req, err := bindPublicMessageRequest(c) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } message, conversation, err := h.widgetService.PublicUpdateMessage(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, messageID, req) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, publicMessagePayload(*message, *conversation)) } // SubmitOfflineMessage handles a visitor submitting a message when agents are offline. // POST /widget/offline_message // Reference: Chatwoot widget SDK — when business_hours are disabled and no agents online, // visitors can still leave their info + message which creates a pending conversation. func (h *WidgetHandler) SubmitOfflineMessage(c *gin.Context) { websiteToken := c.Query("website_token") if websiteToken == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "website_token is required"}) return } // Resolve inbox from website_token inbox, err := h.widgetService.GetInboxByWebsiteToken(c.Request.Context(), websiteToken) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "invalid website_token"}) return } var submission model.WidgetOfflineMessageSubmission if err := c.ShouldBindJSON(&submission); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } referer := c.Request.Referer() browserInfo := c.GetHeader("User-Agent") msg, err := h.widgetService.SubmitOfflineMessage( c.Request.Context(), inbox.ID, inbox.AccountID, &submission, referer, browserInfo, ) if err != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "offline_message": msg, "message": "Your message has been recorded. An agent will respond when available.", }) } func widgetTokenFromRequest(c *gin.Context) string { if token := c.GetHeader("X-Widget-Token"); token != "" { return token } if token := c.GetHeader("X-Auth-Token"); token != "" { return token } if token := c.Query("cw_conversation"); token != "" { return token } if cookie, err := c.Cookie("cw_conversation"); err == nil && cookie != "" { return cookie } return c.Query("widget_token") } func bindWidgetSendMessageRequest(c *gin.Context) (service.WidgetSendMessageRequest, error) { if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") { if err := c.Request.ParseMultipartForm(32 << 20); err != nil { return service.WidgetSendMessageRequest{}, err } form := c.Request.MultipartForm content := firstFormValue(form.Value, "content", "message[content]") contentType := firstFormValue(form.Value, "content_type", "message[content_type]") var conversationID *uint if rawID := firstFormValue(form.Value, "conversation_id", "message[conversation_id]"); rawID != "" { if id, err := strconv.ParseUint(rawID, 10, 64); err == nil && id > 0 { value := uint(id) conversationID = &value } } var replyTo *uint if rawID := firstFormValue(form.Value, "reply_to", "message[reply_to]"); rawID != "" { if id, err := strconv.ParseUint(rawID, 10, 64); err == nil && id > 0 { value := uint(id) replyTo = &value } } attachments := form.Value["message[attachments][]"] if len(attachments) == 0 { attachments = form.Value["attachments[]"] } customAttributes := map[string]any{} for key, values := range form.Value { if strings.HasPrefix(key, "custom_attributes[") && strings.HasSuffix(key, "]") && len(values) > 0 { attrKey := strings.TrimSuffix(strings.TrimPrefix(key, "custom_attributes["), "]") if attrKey != "" { customAttributes[attrKey] = values[0] } } } if len(customAttributes) == 0 { customAttributes = nil } return service.WidgetSendMessageRequest{ Content: content, ContentType: contentType, ConversationID: conversationID, AttachmentIDs: attachments, CustomAttributes: customAttributes, Labels: form.Value["labels[]"], ReplyTo: replyTo, }, nil } var body struct { Content string `json:"content"` ContentType string `json:"content_type"` ConversationID *uint `json:"conversation_id"` Attachments []string `json:"attachments"` CustomAttributes map[string]any `json:"custom_attributes"` Labels []string `json:"labels"` Message struct { Content string `json:"content"` Attachments []string `json:"attachments"` ReplyTo *uint `json:"reply_to"` } `json:"message"` } if err := c.ShouldBindJSON(&body); err != nil { return service.WidgetSendMessageRequest{}, err } content := body.Content if content == "" { content = body.Message.Content } attachments := body.Attachments if len(attachments) == 0 { attachments = body.Message.Attachments } return service.WidgetSendMessageRequest{ Content: content, ContentType: body.ContentType, ConversationID: body.ConversationID, AttachmentIDs: attachments, CustomAttributes: body.CustomAttributes, Labels: body.Labels, ReplyTo: body.Message.ReplyTo, }, nil } func firstFormValue(values map[string][]string, keys ...string) string { for _, key := range keys { if list := values[key]; len(list) > 0 { return list[0] } } return "" } func widgetMessagePayload(message model.Message, conversationID uint) gin.H { return gin.H{ "id": message.ID, "account_id": message.AccountID, "content": message.Content, "inbox_id": message.InboxID, "conversation_id": conversationID, "message_type": widgetMessageType(message.MessageType), "content_type": message.ContentType, "content_attributes": webhookutil.SanitizeOutboundJSON(message.ContentAttributes), "created_at": message.CreatedAt.Unix(), "private": message.Private, "source_id": message.SourceID, } } func widgetMessageType(value string) int { switch strings.ToLower(strings.TrimSpace(value)) { case "incoming": return 0 case "activity": return 2 case "template": return 3 default: return 1 } } func widgetConversationID(conversation model.Conversation) uint { if conversation.DisplayID != nil && *conversation.DisplayID != 0 { return *conversation.DisplayID } return conversation.ID } func widgetAttachmentPayloads(attachments []model.Attachment) []gin.H { payload := make([]gin.H, 0, len(attachments)) for _, attachment := range attachments { payload = append(payload, gin.H{ "id": attachment.ID, "message_id": attachment.MessageID, "account_id": attachment.AccountID, "thumb_url": attachment.ThumbURL, "data_url": attachment.FileURL, "file_size": attachment.FileSize, "file_type": attachment.FileType, "extension": strings.TrimPrefix(strings.ToLower(attachmentExtension(attachment.FileName)), "."), "width": attachment.Width, "height": attachment.Height, }) } return payload } func attachmentExtension(filename string) string { idx := strings.LastIndex(filename, ".") if idx == -1 { return "" } return filename[idx:] } func widgetConversationPayload(conversation model.Conversation) gin.H { return gin.H{ "id": conversation.ID, "uuid": conversation.UUID, "inbox_id": conversation.InboxID, "contact_last_seen_at": conversation.ContactLastSeenAt, "status": conversation.Status, "ai_takeover_active": conversation.AssigneeAgentBotID != nil && conversation.Status == string(model.ConversationStatusPending), "custom_attributes": conversation.CustomAttributes, } } func widgetContactPayload(contact *model.Contact) gin.H { return gin.H{ "id": contact.ID, "identifier": contact.Identifier, "has_email": contact.Email != "", "has_name": contact.Name != "", "has_phone_number": contact.PhoneNumber != "", } } func widgetContactFullPayload(contact *model.Contact) gin.H { return gin.H{ "id": contact.ID, "name": contact.Name, "email": contact.Email, "phone_number": contact.PhoneNumber, "avatar_url": contact.AvatarURL, "identifier": contact.Identifier, "custom_attributes": contact.CustomAttributes, "additional_attributes": contact.AdditionalAttributes, } } func bindPublicContactRequest(c *gin.Context) (service.PublicContactRequest, error) { var body struct { SourceID string `json:"source_id"` Identifier string `json:"identifier"` IdentifierHash string `json:"identifier_hash"` Email string `json:"email"` Name string `json:"name"` AvatarURL string `json:"avatar_url"` PhoneNumber string `json:"phone_number"` CustomAttributes map[string]any `json:"custom_attributes"` AdditionalAttributes map[string]any `json:"additional_attributes"` } if err := c.ShouldBindJSON(&body); err != nil && c.Request.ContentLength != 0 { return service.PublicContactRequest{}, err } if body.SourceID == "" { body.SourceID = c.Query("source_id") } if body.Identifier == "" { body.Identifier = c.Query("identifier") } if body.IdentifierHash == "" { body.IdentifierHash = c.Query("identifier_hash") } return service.PublicContactRequest{ SourceID: body.SourceID, Identifier: body.Identifier, IdentifierHash: body.IdentifierHash, Email: body.Email, Name: body.Name, AvatarURL: body.AvatarURL, PhoneNumber: body.PhoneNumber, CustomAttributes: body.CustomAttributes, AdditionalAttributes: body.AdditionalAttributes, }, nil } func bindPublicMessageRequest(c *gin.Context) (service.PublicMessageRequest, error) { if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") { if err := c.Request.ParseMultipartForm(32 << 20); err != nil { return service.PublicMessageRequest{}, err } form := c.Request.MultipartForm attachments := form.Value["attachments[]"] if len(attachments) == 0 { attachments = form.Value["message[attachments][]"] } return service.PublicMessageRequest{ Content: firstFormValue(form.Value, "content", "message[content]"), EchoID: firstFormValue(form.Value, "echo_id", "message[echo_id]"), AttachmentIDs: attachments, }, nil } var body struct { Content string `json:"content"` EchoID string `json:"echo_id"` SubmittedValues []map[string]any `json:"submitted_values"` Attachments []string `json:"attachments"` } if err := c.ShouldBindJSON(&body); err != nil { return service.PublicMessageRequest{}, err } return service.PublicMessageRequest{ Content: body.Content, EchoID: body.EchoID, SubmittedValues: body.SubmittedValues, AttachmentIDs: body.Attachments, }, nil } func publicInboxPayload(inbox *model.Inbox) gin.H { workingHours := map[string]any{} return gin.H{ "name": inbox.Name, "timezone": inbox.Timezone, "working_hours": workingHours, "working_hours_enabled": inbox.WorkingHoursEnabled, "csat_survey_enabled": inbox.CsatSurveyEnabled, "greeting_enabled": inbox.GreetingEnabled, } } func publicContactPayload(contactInbox *model.ContactInbox, contact *model.Contact) gin.H { return gin.H{ "source_id": contactInbox.SourceID, "pubsub_token": contactInbox.PubsubToken, "id": contact.ID, "name": contact.Name, "email": contact.Email, "phone_number": contact.PhoneNumber, } } func (h *WidgetHandler) publicConversationPayload(ctx context.Context, conversation model.Conversation, messages []model.Message) gin.H { payload := gin.H{ "id": publicDisplayID(conversation), "internal_id": conversation.ID, "uuid": conversation.UUID, "inbox_id": conversation.InboxID, "contact_last_seen_at": publicUnix(conversation.ContactLastSeenAt), "status": conversation.Status, "agent_last_seen_at": publicUnix(conversation.AgentLastSeenAt), "contact": gin.H{"id": conversation.ContactID}, } messagePayloads := make([]gin.H, 0, len(messages)) for _, message := range messages { messagePayload := publicMessagePayload(message, conversation) if attachments, err := h.widgetService.GetMessageAttachments(ctx, message.ID); err == nil && len(attachments) > 0 { messagePayload["attachments"] = widgetAttachmentPayloads(attachments) } messagePayloads = append(messagePayloads, messagePayload) } payload["messages"] = messagePayloads return payload } func publicMessagePayload(message model.Message, conversation model.Conversation) gin.H { payload := gin.H{ "id": message.ID, "content": message.Content, "message_type": message.MessageType, "content_type": message.ContentType, "content_attributes": webhookutil.SanitizeOutboundJSON(message.ContentAttributes), "created_at": message.CreatedAt.Unix(), "conversation_id": publicDisplayID(conversation), } return payload } func publicUintParam(c *gin.Context, name string) (uint, bool) { value, err := strconv.ParseUint(c.Param(name), 10, 64) if err != nil || value == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": name + " must be a positive integer"}) return 0, false } return uint(value), true } func publicDisplayID(conversation model.Conversation) uint { if conversation.DisplayID != nil && *conversation.DisplayID != 0 { return *conversation.DisplayID } return conversation.ID } func publicUnix(value *int64) int64 { if value == nil { return 0 } return *value } func isChatwootWidgetRoute(c *gin.Context) bool { return strings.HasPrefix(c.FullPath(), "/api/v1/widget/") } func widgetConversationNotFoundLike(err error) bool { if errors.Is(err, service.ErrWidgetConversationNotFound) { return true } msg := err.Error() return strings.Contains(msg, "invalid widget_token") || strings.Contains(msg, "conversation not found") || strings.Contains(msg, "record not found") } func widgetWebsiteTokenNotFoundLike(err error) bool { msg := err.Error() return strings.Contains(msg, "website_token is required") || strings.Contains(msg, "no inbox found for website_token") || strings.Contains(msg, "no web_widget inboxes found") } func widgetErrorStatus(err error) int { if errors.Is(err, service.ErrWidgetMessageContentTooLong) { return http.StatusUnprocessableEntity } msg := err.Error() if strings.Contains(msg, "invalid widget_token") || strings.Contains(msg, "HMAC failed") { return http.StatusUnauthorized } if strings.Contains(msg, "not found") { return http.StatusNotFound } if strings.Contains(msg, "CSAT survey after 14 days") { return http.StatusUnprocessableEntity } return http.StatusBadRequest }