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.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } // 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) } // 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 := c.GetHeader("X-Widget-Token") if widgetToken == "" { widgetToken = c.Query("widget_token") } if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } var req service.WidgetSendMessageRequest if err := c.ShouldBindJSON(&req); 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 } c.JSON(http.StatusOK, resp) } // 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 := c.GetHeader("X-Widget-Token") if widgetToken == "" { widgetToken = c.Query("widget_token") } 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 } c.JSON(http.StatusOK, gin.H{"conversations": conversations}) } // 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 := c.GetHeader("X-Widget-Token") if widgetToken == "" { widgetToken = c.Query("widget_token") } 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 := c.GetHeader("X-Widget-Token") if widgetToken == "" { widgetToken = c.Query("widget_token") } 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 := c.GetHeader("X-Widget-Token") if widgetToken == "" { widgetToken = c.Query("widget_token") } 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"` } 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.UpdateContact(c.Request.Context(), widgetToken, req.Name, req.Email) 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, 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 := c.GetHeader("X-Widget-Token") if widgetToken == "" { widgetToken = c.Query("widget_token") } 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 } var req struct { Typing bool `json:"typing"` } if err := c.ShouldBindJSON(&req); err != nil { // Default to typing=true if no body provided req.Typing = true } 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"}) } // 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.", }) }