327 lines
9.9 KiB
Go
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.",
|
|
})
|
|
} |