second commit
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
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.",
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user