package widget import ( "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" ) // 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, } } // 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") } // 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") } resp, err := h.widgetService.Init(c.Request.Context(), req) if err != nil { 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 } c.JSON(http.StatusOK, gin.H{ "website_channel_config": 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, }, "contact": contact, "global_config": gin.H{}, }) } // 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 { 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 } if c.FullPath() == "/widget/messages" { c.JSON(http.StatusOK, resp) return } c.JSON(http.StatusOK, widgetMessagePayload(resp.Message, resp.ConversationID)) } // 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 } offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "25")) if limit <= 0 || limit > 100 { limit = 25 } messages, total, conversation, err := h.widgetService.GetLatestConversationMessages(c.Request.Context(), widgetToken, 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 } payload := make([]gin.H, 0, len(messages)) for _, msg := range messages { payload = append(payload, widgetMessagePayload(msg, msg.ConversationID)) } meta := gin.H{"total": total, "offset": offset, "limit": limit} 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, resp.ConversationID)} 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 == "" { 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"` CustomAttributes map[string]any `json:"custom_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, CustomAttributes: req.CustomAttributes, }) 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, widgetContactPayload(contact)) } // GetContact implements Chatwoot's GET /api/v1/widget/contact endpoint. func (h *WidgetHandler) GetContact(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } contact, err := h.widgetService.GetContact(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, widgetContactPayload(contact)) } func (h *WidgetHandler) DestroyContactCustomAttributes(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { 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 { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, contact) } // 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.JSON(http.StatusOK, gin.H{"status": "ok"}) } func (h *WidgetHandler) UpdateLastSeen(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } conversation, err := h.widgetService.UpdateLastSeen(c.Request.Context(), widgetToken) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"contact_last_seen_at": conversation.ContactLastSeenAt}) } func (h *WidgetHandler) ToggleStatus(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } conversation, err := h.widgetService.ResolveLatestConversation(c.Request.Context(), widgetToken) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, widgetConversationPayload(*conversation)) } func (h *WidgetHandler) SetConversationCustomAttributes(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { 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 } conversation, err := h.widgetService.SetLatestConversationCustomAttributes(c.Request.Context(), widgetToken, req.CustomAttributes) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, conversation) } func (h *WidgetHandler) DestroyConversationCustomAttributes(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { 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 { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, 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 } return c.Query("widget_token") } func bindWidgetSendMessageRequest(c *gin.Context) (service.WidgetSendMessageRequest, error) { var body struct { Content string `json:"content"` ContentType string `json:"content_type"` ConversationID *uint `json:"conversation_id"` Message struct { Content string `json:"content"` } `json:"message"` } if err := c.ShouldBindJSON(&body); err != nil { return service.WidgetSendMessageRequest{}, err } content := body.Content if content == "" { content = body.Message.Content } return service.WidgetSendMessageRequest{ Content: content, ContentType: body.ContentType, ConversationID: body.ConversationID, }, nil } func widgetMessagePayload(message model.Message, conversationID uint) gin.H { return gin.H{ "id": message.ID, "content": message.Content, "inbox_id": message.InboxID, "conversation_id": conversationID, "message_type": message.MessageType, "content_type": message.ContentType, "content_attributes": message.ContentAttributes, "created_at": message.CreatedAt.Unix(), "private": message.Private, "source_id": message.SourceID, } } 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, "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 != "", } }