Files
gochat/internal/handler/widget/widget_handler.go
T
2026-06-04 15:44:48 +08:00

327 lines
9.9 KiB
Go

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.",
})
}