Files
gochat/backend/internal/handler/widget/widget_handler.go
T
Rogeeandrogee a2e4f9a1e8 HH-540: scope iframe policy to Widget page (#123)
* fix(HH-540): scope iframe policy to widget page

* fix(HH-540): reject non-string allowed domains

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-23 18:37:08 +08:00

1617 lines
52 KiB
Go

package widget
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/internal/webhookutil"
)
// 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,
}
}
// AllowIframeRequests applies Chatwoot's WidgetsController iframe policy only
// to the widget HTML response.
func (h *WidgetHandler) AllowIframeRequests(c *gin.Context) {
inbox, err := h.widgetService.GetInboxByWebsiteToken(c.Request.Context(), strings.TrimSpace(c.Query("website_token")))
if err != nil {
c.Status(http.StatusNotFound)
c.Abort()
return
}
config, err := service.ParseWebWidgetConfig(inbox.ChannelConfig)
if err != nil {
c.Status(http.StatusInternalServerError)
c.Abort()
return
}
frameAncestors, ok := widgetFrameAncestors(config.AllowedDomains)
if !ok {
c.Status(http.StatusInternalServerError)
c.Abort()
return
}
c.Writer.Header().Del("X-Frame-Options")
c.Header("Content-Security-Policy", widgetContentSecurityPolicy(c.Writer.Header().Get("Content-Security-Policy"), frameAncestors))
}
func widgetFrameAncestors(allowedDomains string) (string, bool) {
domains := make([]string, 0)
for _, domain := range strings.Split(allowedDomains, ",") {
domain = strings.TrimSpace(domain)
if domain == "" {
continue
}
if strings.ContainsAny(domain, "; \t\r\n") {
return "", false
}
domains = append(domains, domain)
}
return strings.Join(domains, " "), true
}
func widgetContentSecurityPolicy(policy, frameAncestors string) string {
directives := strings.Split(policy, ";")
result := make([]string, 0, len(directives))
found := false
for _, directive := range directives {
directive = strings.TrimSpace(directive)
if strings.HasPrefix(directive, "frame-ancestors ") {
found = true
if frameAncestors != "" {
result = append(result, "frame-ancestors "+frameAncestors)
}
continue
}
if directive != "" {
result = append(result, directive)
}
}
if frameAncestors != "" && !found {
result = append(result, "frame-ancestors "+frameAncestors)
}
return strings.Join(result, "; ")
}
// 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")
}
if req.WidgetToken == "" {
req.WidgetToken = widgetTokenFromRequest(c)
}
// 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")
}
if req.WidgetToken == "" {
req.WidgetToken = widgetTokenFromRequest(c)
}
resp, err := h.widgetService.Init(c.Request.Context(), req)
if err != nil {
if widgetWebsiteTokenNotFoundLike(err) {
c.Status(http.StatusNotFound)
return
}
if strings.Contains(err.Error(), "Account is suspended") {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Account is suspended"})
return
}
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
}
channelConfig := 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,
"enabledFeatures": widgetEnabledFeatures(resp.WidgetConfig),
}
if resp.WidgetConfig.PreChatFieldsEnabled {
channelConfig["preChatFormEnabled"] = true
channelConfig["preChatFormOptions"] = gin.H{
"pre_chat_message": resp.WidgetConfig.PreChatMessage,
"pre_chat_fields": defaultWidgetPreChatFields(),
}
} else {
channelConfig["preChatFormEnabled"] = false
channelConfig["preChatFormOptions"] = gin.H{"pre_chat_message": "", "pre_chat_fields": []gin.H{}}
}
c.JSON(http.StatusOK, gin.H{
"website_channel_config": channelConfig,
"contact": contact,
"global_config": gin.H{
"directUploadsEnabled": true,
"maximumFileUploadSize": 40,
},
})
}
func defaultWidgetPreChatFields() []gin.H {
return []gin.H{
{"label": "Email Id", "name": "emailAddress", "type": "email", "field_type": "standard", "required": false, "enabled": true},
{"label": "Full name", "name": "fullName", "type": "text", "field_type": "standard", "required": true, "enabled": true},
}
}
func widgetEnabledFeatures(config service.WebWidgetConfig) []string {
if len(config.SelectedFeatureFlags) > 0 {
return config.SelectedFeatureFlags
}
return []string{"attachments", "emoji_picker", "end_conversation"}
}
// 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 {
if errors.Is(err, service.ErrWidgetMessageContentTooLong) && c.FullPath() != "/widget/messages" {
c.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error()})
return
}
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
}
conversationID := resp.ConversationID
if conversation, lookupErr := h.widgetService.GetConversation(c.Request.Context(), widgetToken, resp.ConversationID); lookupErr == nil {
conversationID = widgetConversationID(*conversation)
}
payload := widgetMessagePayload(resp.Message, conversationID)
if len(resp.Attachments) > 0 {
payload["attachments"] = widgetAttachmentPayloads(resp.Attachments)
}
c.JSON(http.StatusOK, payload)
}
func (h *WidgetHandler) UpdateMessage(c *gin.Context) {
widgetToken := widgetTokenFromRequest(c)
if widgetToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"})
return
}
messageID, err := strconv.ParseUint(c.Param("message_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
return
}
var req struct {
Contact struct {
Email string `json:"email"`
Name string `json:"name"`
} `json:"contact"`
Message struct {
SubmittedValues []map[string]any `json:"submitted_values"`
} `json:"message"`
}
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.UpdateMessage(c.Request.Context(), service.WidgetMessageUpdate{
MessageID: uint(messageID),
WidgetToken: widgetToken,
ContactEmail: req.Contact.Email,
ContactName: req.Contact.Name,
SubmittedValues: req.Message.SubmittedValues,
})
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"contact": widgetContactFullPayload(contact)})
}
// 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
}
after, _ := strconv.ParseUint(c.DefaultQuery("after", "0"), 10, 64)
before, _ := strconv.ParseUint(c.DefaultQuery("before", "0"), 10, 64)
messages, total, conversation, err := h.widgetService.GetLatestConversationMessages(c.Request.Context(), widgetToken, uint(after), uint(before))
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))
conversationID := uint(0)
if conversation != nil {
conversationID = widgetConversationID(*conversation)
}
for _, msg := range messages {
messagePayload := widgetMessagePayload(msg, conversationID)
if attachments, err := h.widgetService.GetMessageAttachments(c.Request.Context(), msg.ID); err == nil && len(attachments) > 0 {
messagePayload["attachments"] = widgetAttachmentPayloads(attachments)
}
payload = append(payload, messagePayload)
}
meta := gin.H{"total": total}
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, widgetConversationID(*conversation))}
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 == "" {
if isChatwootWidgetRoute(c) {
c.Status(http.StatusNotFound)
return
}
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"`
Identifier string `json:"identifier,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
CustomAttributes map[string]any `json:"custom_attributes,omitempty"`
AdditionalAttributes map[string]any `json:"additional_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,
Identifier: req.Identifier,
AvatarURL: req.AvatarURL,
CustomAttributes: req.CustomAttributes,
AdditionalAttributes: req.AdditionalAttributes,
})
if err != nil {
if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) {
c.Status(http.StatusNotFound)
return
}
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 == "" {
if isChatwootWidgetRoute(c) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"})
return
}
contact, err := h.widgetService.GetContact(c.Request.Context(), widgetToken)
if err != nil {
if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) {
c.Status(http.StatusNotFound)
return
}
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 == "" {
if isChatwootWidgetRoute(c) {
c.Status(http.StatusNotFound)
return
}
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 {
if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, contact)
}
func (h *WidgetHandler) SetUser(c *gin.Context) {
widgetToken := widgetTokenFromRequest(c)
if widgetToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"})
return
}
var req struct {
Identifier string `json:"identifier"`
IdentifierHash string `json:"identifier_hash"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
PhoneNumber string `json:"phone_number"`
CustomAttributes map[string]any `json:"custom_attributes"`
AdditionalAttributes map[string]any `json:"additional_attributes"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
identifierHash := req.IdentifierHash
if identifierHash == "" {
identifierHash = c.Query("identifier_hash")
}
resp, err := h.widgetService.SetUser(c.Request.Context(), service.WidgetSetUserRequest{
WebsiteToken: c.Query("website_token"),
WidgetToken: widgetToken,
Identifier: req.Identifier,
IdentifierHash: identifierHash,
Email: req.Email,
Name: req.Name,
AvatarURL: req.AvatarURL,
PhoneNumber: req.PhoneNumber,
CustomAttributes: req.CustomAttributes,
AdditionalAttributes: req.AdditionalAttributes,
})
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
payload := widgetContactPayload(resp.Contact)
if resp.WidgetAuthToken != "" {
payload["widget_auth_token"] = resp.WidgetAuthToken
}
c.JSON(http.StatusOK, payload)
}
// 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.Status(http.StatusOK)
}
func (h *WidgetHandler) UpdateLastSeen(c *gin.Context) {
widgetToken := widgetTokenFromRequest(c)
if widgetToken == "" {
if isChatwootWidgetRoute(c) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"})
return
}
_, err := h.widgetService.UpdateLastSeen(c.Request.Context(), widgetToken)
if err != nil {
if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
func (h *WidgetHandler) ToggleStatus(c *gin.Context) {
widgetToken := widgetTokenFromRequest(c)
if widgetToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"})
return
}
_, err := h.widgetService.ResolveLatestConversation(c.Request.Context(), widgetToken)
if err != nil {
if errors.Is(err, service.ErrWidgetEndConversationDisabled) {
c.Status(http.StatusForbidden)
return
}
if errors.Is(err, service.ErrWidgetConversationNotFound) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
func (h *WidgetHandler) SetConversationCustomAttributes(c *gin.Context) {
widgetToken := widgetTokenFromRequest(c)
if widgetToken == "" {
if isChatwootWidgetRoute(c) {
c.Status(http.StatusNotFound)
return
}
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
}
_, err := h.widgetService.SetLatestConversationCustomAttributes(c.Request.Context(), widgetToken, req.CustomAttributes)
if err != nil {
if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
func (h *WidgetHandler) DestroyConversationCustomAttributes(c *gin.Context) {
widgetToken := widgetTokenFromRequest(c)
if widgetToken == "" {
if isChatwootWidgetRoute(c) {
c.Status(http.StatusNotFound)
return
}
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 {
if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, conversation)
}
func (h *WidgetHandler) ListInboxMembers(c *gin.Context) {
websiteToken := c.Query("website_token")
if websiteToken == "" {
if isChatwootWidgetRoute(c) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "website_token is required"})
return
}
members, err := h.widgetService.GetInboxMembersByWebsiteToken(c.Request.Context(), websiteToken)
if err != nil {
if isChatwootWidgetRoute(c) && widgetWebsiteTokenNotFoundLike(err) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"payload": members})
}
func (h *WidgetHandler) ListCampaigns(c *gin.Context) {
websiteToken := c.Query("website_token")
if websiteToken == "" {
if isChatwootWidgetRoute(c) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "website_token is required"})
return
}
campaigns, err := h.widgetService.GetCampaignsByWebsiteToken(c.Request.Context(), websiteToken)
if err != nil {
if isChatwootWidgetRoute(c) && widgetWebsiteTokenNotFoundLike(err) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, campaigns)
}
func (h *WidgetHandler) CreateEvent(c *gin.Context) {
var req struct {
Name string `json:"name" form:"name"`
WebsiteToken string `json:"website_token" form:"website_token"`
EventInfo map[string]any `json:"event_info" form:"event_info"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
websiteToken := strings.TrimSpace(c.Query("website_token"))
if websiteToken == "" {
websiteToken = strings.TrimSpace(req.WebsiteToken)
}
if err := h.widgetService.TrackEvent(c.Request.Context(), websiteToken, widgetTokenFromRequest(c), req.Name, req.EventInfo); err != nil {
if isChatwootWidgetRoute(c) && widgetWebsiteTokenNotFoundLike(err) {
c.Status(http.StatusNotFound)
return
}
if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func (h *WidgetHandler) AddLabel(c *gin.Context) {
widgetToken := widgetTokenFromRequest(c)
if widgetToken == "" {
if isChatwootWidgetRoute(c) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"})
return
}
var req struct {
Label string `json:"label"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
if err := h.widgetService.AddLabelToLatestConversation(c.Request.Context(), widgetToken, req.Label); err != nil {
if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func (h *WidgetHandler) RemoveLabel(c *gin.Context) {
widgetToken := widgetTokenFromRequest(c)
if widgetToken == "" {
if isChatwootWidgetRoute(c) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"})
return
}
if err := h.widgetService.RemoveLabelFromLatestConversation(c.Request.Context(), widgetToken, c.Param("label_id")); err != nil {
if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) {
c.Status(http.StatusNotFound)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func (h *WidgetHandler) SendTranscript(c *gin.Context) {
widgetToken := widgetTokenFromRequest(c)
if widgetToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"})
return
}
if err := h.widgetService.SendTranscript(c.Request.Context(), widgetToken); err != nil {
if errors.Is(err, service.ErrWidgetConversationNotFound) {
c.Status(http.StatusTooManyRequests)
return
}
if errors.Is(err, service.ErrEmailTranscriptDisabled) {
c.Status(http.StatusPaymentRequired)
return
}
if errors.Is(err, service.ErrEmailRateLimited) {
c.Status(http.StatusTooManyRequests)
return
}
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
func (h *WidgetHandler) AddDyteParticipantToMeeting(c *gin.Context) {
var req struct {
MessageID uint `json:"message_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
resp, err := h.widgetService.AddDyteParticipant(c.Request.Context(), c.Query("website_token"), widgetTokenFromRequest(c), req.MessageID)
if err != nil {
status := http.StatusUnprocessableEntity
if strings.Contains(err.Error(), "website_token") || strings.Contains(err.Error(), "widget_token") {
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
func (h *WidgetHandler) PublicInboxShow(c *gin.Context) {
inbox, identityValidation, err := h.widgetService.PublicGetInbox(c.Request.Context(), c.Param("inbox_id"))
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
payload := publicInboxPayload(inbox)
payload["identifier"] = c.Param("inbox_id")
payload["identity_validation_enabled"] = identityValidation
c.JSON(http.StatusOK, payload)
}
func (h *WidgetHandler) PublicCreateContact(c *gin.Context) {
req, err := bindPublicContactRequest(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
resp, err := h.widgetService.PublicCreateContact(c.Request.Context(), c.Param("inbox_id"), req)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, publicContactPayload(resp.ContactInbox, resp.Contact))
}
func (h *WidgetHandler) PublicGetContact(c *gin.Context) {
resp, err := h.widgetService.PublicGetContact(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"))
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, publicContactPayload(resp.ContactInbox, resp.Contact))
}
func (h *WidgetHandler) PublicUpdateContact(c *gin.Context) {
req, err := bindPublicContactRequest(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
resp, err := h.widgetService.PublicUpdateContact(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), req)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, publicContactPayload(resp.ContactInbox, resp.Contact))
}
func (h *WidgetHandler) PublicListConversations(c *gin.Context) {
conversations, err := h.widgetService.PublicListConversations(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"))
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
payload := make([]gin.H, 0, len(conversations))
for _, conversation := range conversations {
messages, _, _, err := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), publicDisplayID(conversation), service.PublicMessageListOptions{})
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
payload = append(payload, h.publicConversationPayload(c.Request.Context(), conversation, messages))
}
c.JSON(http.StatusOK, payload)
}
func (h *WidgetHandler) PublicCreateConversation(c *gin.Context) {
var req struct {
CustomAttributes map[string]any `json:"custom_attributes"`
}
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
}
conversation, err := h.widgetService.PublicCreateConversation(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), service.PublicConversationRequest{CustomAttributes: req.CustomAttributes})
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, h.publicConversationPayload(c.Request.Context(), *conversation, nil))
}
func (h *WidgetHandler) PublicGetConversation(c *gin.Context) {
conversationID, ok := publicUintParam(c, "conversation_id")
if !ok {
return
}
conversation, err := h.widgetService.PublicGetConversation(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
messages, _, _, _ := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, service.PublicMessageListOptions{})
c.JSON(http.StatusOK, h.publicConversationPayload(c.Request.Context(), *conversation, messages))
}
func (h *WidgetHandler) PublicToggleStatus(c *gin.Context) {
conversationID, ok := publicUintParam(c, "conversation_id")
if !ok {
return
}
conversation, err := h.widgetService.PublicToggleStatus(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, h.publicConversationPayload(c.Request.Context(), *conversation, nil))
}
func (h *WidgetHandler) PublicToggleTyping(c *gin.Context) {
conversationID, ok := publicUintParam(c, "conversation_id")
if !ok {
return
}
var req struct {
TypingStatus string `json:"typing_status"`
}
_ = c.ShouldBindJSON(&req)
if req.TypingStatus != "on" && req.TypingStatus != "off" {
c.Status(http.StatusOK)
return
}
if err := h.widgetService.PublicToggleTyping(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, req.TypingStatus != "off"); err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
func (h *WidgetHandler) PublicUpdateLastSeen(c *gin.Context) {
conversationID, ok := publicUintParam(c, "conversation_id")
if !ok {
return
}
if _, err := h.widgetService.PublicUpdateLastSeen(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID); err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
func (h *WidgetHandler) PublicListMessages(c *gin.Context) {
conversationID, ok := publicUintParam(c, "conversation_id")
if !ok {
return
}
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "0"))
before, _ := strconv.ParseUint(c.Query("before"), 10, 64)
messages, _, conversation, err := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, service.PublicMessageListOptions{Before: uint(before), Offset: offset, Limit: limit})
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
payload := make([]gin.H, 0, len(messages))
for _, message := range messages {
messagePayload := publicMessagePayload(message, *conversation)
if attachments, err := h.widgetService.GetMessageAttachments(c.Request.Context(), message.ID); err == nil && len(attachments) > 0 {
messagePayload["attachments"] = widgetAttachmentPayloads(attachments)
}
payload = append(payload, messagePayload)
}
c.JSON(http.StatusOK, payload)
}
func (h *WidgetHandler) PublicCreateMessage(c *gin.Context) {
conversationID, ok := publicUintParam(c, "conversation_id")
if !ok {
return
}
req, err := bindPublicMessageRequest(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
message, conversation, attachments, err := h.widgetService.PublicCreateMessage(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, req)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
payload := publicMessagePayload(*message, *conversation)
if len(attachments) > 0 {
payload["attachments"] = widgetAttachmentPayloads(attachments)
}
c.JSON(http.StatusOK, payload)
}
func (h *WidgetHandler) PublicUpdateMessage(c *gin.Context) {
conversationID, ok := publicUintParam(c, "conversation_id")
if !ok {
return
}
messageID, ok := publicUintParam(c, "message_id")
if !ok {
return
}
req, err := bindPublicMessageRequest(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
message, conversation, err := h.widgetService.PublicUpdateMessage(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, messageID, req)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, publicMessagePayload(*message, *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
}
if token := c.Query("cw_conversation"); token != "" {
return token
}
if cookie, err := c.Cookie("cw_conversation"); err == nil && cookie != "" {
return cookie
}
return c.Query("widget_token")
}
func bindWidgetSendMessageRequest(c *gin.Context) (service.WidgetSendMessageRequest, error) {
if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
return service.WidgetSendMessageRequest{}, err
}
form := c.Request.MultipartForm
content := firstFormValue(form.Value, "content", "message[content]")
contentType := firstFormValue(form.Value, "content_type", "message[content_type]")
var conversationID *uint
if rawID := firstFormValue(form.Value, "conversation_id", "message[conversation_id]"); rawID != "" {
if id, err := strconv.ParseUint(rawID, 10, 64); err == nil && id > 0 {
value := uint(id)
conversationID = &value
}
}
var replyTo *uint
if rawID := firstFormValue(form.Value, "reply_to", "message[reply_to]"); rawID != "" {
if id, err := strconv.ParseUint(rawID, 10, 64); err == nil && id > 0 {
value := uint(id)
replyTo = &value
}
}
attachments := form.Value["message[attachments][]"]
if len(attachments) == 0 {
attachments = form.Value["attachments[]"]
}
customAttributes := map[string]any{}
for key, values := range form.Value {
if strings.HasPrefix(key, "custom_attributes[") && strings.HasSuffix(key, "]") && len(values) > 0 {
attrKey := strings.TrimSuffix(strings.TrimPrefix(key, "custom_attributes["), "]")
if attrKey != "" {
customAttributes[attrKey] = values[0]
}
}
}
if len(customAttributes) == 0 {
customAttributes = nil
}
return service.WidgetSendMessageRequest{
Content: content,
ContentType: contentType,
ConversationID: conversationID,
AttachmentIDs: attachments,
CustomAttributes: customAttributes,
Labels: form.Value["labels[]"],
ReplyTo: replyTo,
}, nil
}
var body struct {
Content string `json:"content"`
ContentType string `json:"content_type"`
ConversationID *uint `json:"conversation_id"`
Attachments []string `json:"attachments"`
CustomAttributes map[string]any `json:"custom_attributes"`
Labels []string `json:"labels"`
Message struct {
Content string `json:"content"`
Attachments []string `json:"attachments"`
ReplyTo *uint `json:"reply_to"`
} `json:"message"`
}
if err := c.ShouldBindJSON(&body); err != nil {
return service.WidgetSendMessageRequest{}, err
}
content := body.Content
if content == "" {
content = body.Message.Content
}
attachments := body.Attachments
if len(attachments) == 0 {
attachments = body.Message.Attachments
}
return service.WidgetSendMessageRequest{
Content: content,
ContentType: body.ContentType,
ConversationID: body.ConversationID,
AttachmentIDs: attachments,
CustomAttributes: body.CustomAttributes,
Labels: body.Labels,
ReplyTo: body.Message.ReplyTo,
}, nil
}
func firstFormValue(values map[string][]string, keys ...string) string {
for _, key := range keys {
if list := values[key]; len(list) > 0 {
return list[0]
}
}
return ""
}
func widgetMessagePayload(message model.Message, conversationID uint) gin.H {
return gin.H{
"id": message.ID,
"account_id": message.AccountID,
"content": message.Content,
"inbox_id": message.InboxID,
"conversation_id": conversationID,
"message_type": widgetMessageType(message.MessageType),
"content_type": message.ContentType,
"content_attributes": webhookutil.SanitizeOutboundJSON(message.ContentAttributes),
"created_at": message.CreatedAt.Unix(),
"private": message.Private,
"source_id": message.SourceID,
}
}
func widgetMessageType(value string) int {
switch strings.ToLower(strings.TrimSpace(value)) {
case "incoming":
return 0
case "activity":
return 2
case "template":
return 3
default:
return 1
}
}
func widgetConversationID(conversation model.Conversation) uint {
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
return *conversation.DisplayID
}
return conversation.ID
}
func widgetAttachmentPayloads(attachments []model.Attachment) []gin.H {
payload := make([]gin.H, 0, len(attachments))
for _, attachment := range attachments {
payload = append(payload, gin.H{
"id": attachment.ID,
"message_id": attachment.MessageID,
"account_id": attachment.AccountID,
"thumb_url": attachment.ThumbURL,
"data_url": attachment.FileURL,
"file_size": attachment.FileSize,
"file_type": attachment.FileType,
"extension": strings.TrimPrefix(strings.ToLower(attachmentExtension(attachment.FileName)), "."),
"width": attachment.Width,
"height": attachment.Height,
})
}
return payload
}
func attachmentExtension(filename string) string {
idx := strings.LastIndex(filename, ".")
if idx == -1 {
return ""
}
return filename[idx:]
}
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 != "",
}
}
func widgetContactFullPayload(contact *model.Contact) gin.H {
return gin.H{
"id": contact.ID,
"name": contact.Name,
"email": contact.Email,
"phone_number": contact.PhoneNumber,
"avatar_url": contact.AvatarURL,
"identifier": contact.Identifier,
"custom_attributes": contact.CustomAttributes,
"additional_attributes": contact.AdditionalAttributes,
}
}
func bindPublicContactRequest(c *gin.Context) (service.PublicContactRequest, error) {
var body struct {
SourceID string `json:"source_id"`
Identifier string `json:"identifier"`
IdentifierHash string `json:"identifier_hash"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
PhoneNumber string `json:"phone_number"`
CustomAttributes map[string]any `json:"custom_attributes"`
AdditionalAttributes map[string]any `json:"additional_attributes"`
}
if err := c.ShouldBindJSON(&body); err != nil && c.Request.ContentLength != 0 {
return service.PublicContactRequest{}, err
}
if body.SourceID == "" {
body.SourceID = c.Query("source_id")
}
if body.Identifier == "" {
body.Identifier = c.Query("identifier")
}
if body.IdentifierHash == "" {
body.IdentifierHash = c.Query("identifier_hash")
}
return service.PublicContactRequest{
SourceID: body.SourceID,
Identifier: body.Identifier,
IdentifierHash: body.IdentifierHash,
Email: body.Email,
Name: body.Name,
AvatarURL: body.AvatarURL,
PhoneNumber: body.PhoneNumber,
CustomAttributes: body.CustomAttributes,
AdditionalAttributes: body.AdditionalAttributes,
}, nil
}
func bindPublicMessageRequest(c *gin.Context) (service.PublicMessageRequest, error) {
if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
return service.PublicMessageRequest{}, err
}
form := c.Request.MultipartForm
attachments := form.Value["attachments[]"]
if len(attachments) == 0 {
attachments = form.Value["message[attachments][]"]
}
return service.PublicMessageRequest{
Content: firstFormValue(form.Value, "content", "message[content]"),
EchoID: firstFormValue(form.Value, "echo_id", "message[echo_id]"),
AttachmentIDs: attachments,
}, nil
}
var body struct {
Content string `json:"content"`
EchoID string `json:"echo_id"`
SubmittedValues []map[string]any `json:"submitted_values"`
Attachments []string `json:"attachments"`
}
if err := c.ShouldBindJSON(&body); err != nil {
return service.PublicMessageRequest{}, err
}
return service.PublicMessageRequest{
Content: body.Content,
EchoID: body.EchoID,
SubmittedValues: body.SubmittedValues,
AttachmentIDs: body.Attachments,
}, nil
}
func publicInboxPayload(inbox *model.Inbox) gin.H {
workingHours := map[string]any{}
return gin.H{
"name": inbox.Name,
"timezone": inbox.Timezone,
"working_hours": workingHours,
"working_hours_enabled": inbox.WorkingHoursEnabled,
"csat_survey_enabled": inbox.CsatSurveyEnabled,
"greeting_enabled": inbox.GreetingEnabled,
}
}
func publicContactPayload(contactInbox *model.ContactInbox, contact *model.Contact) gin.H {
return gin.H{
"source_id": contactInbox.SourceID,
"pubsub_token": contactInbox.PubsubToken,
"id": contact.ID,
"name": contact.Name,
"email": contact.Email,
"phone_number": contact.PhoneNumber,
}
}
func (h *WidgetHandler) publicConversationPayload(ctx context.Context, conversation model.Conversation, messages []model.Message) gin.H {
payload := gin.H{
"id": publicDisplayID(conversation),
"internal_id": conversation.ID,
"uuid": conversation.UUID,
"inbox_id": conversation.InboxID,
"contact_last_seen_at": publicUnix(conversation.ContactLastSeenAt),
"status": conversation.Status,
"agent_last_seen_at": publicUnix(conversation.AgentLastSeenAt),
"contact": gin.H{"id": conversation.ContactID},
}
messagePayloads := make([]gin.H, 0, len(messages))
for _, message := range messages {
messagePayload := publicMessagePayload(message, conversation)
if attachments, err := h.widgetService.GetMessageAttachments(ctx, message.ID); err == nil && len(attachments) > 0 {
messagePayload["attachments"] = widgetAttachmentPayloads(attachments)
}
messagePayloads = append(messagePayloads, messagePayload)
}
payload["messages"] = messagePayloads
return payload
}
func publicMessagePayload(message model.Message, conversation model.Conversation) gin.H {
payload := gin.H{
"id": message.ID,
"content": message.Content,
"message_type": message.MessageType,
"content_type": message.ContentType,
"content_attributes": webhookutil.SanitizeOutboundJSON(message.ContentAttributes),
"created_at": message.CreatedAt.Unix(),
"conversation_id": publicDisplayID(conversation),
}
return payload
}
func publicUintParam(c *gin.Context, name string) (uint, bool) {
value, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || value == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": name + " must be a positive integer"})
return 0, false
}
return uint(value), true
}
func publicDisplayID(conversation model.Conversation) uint {
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
return *conversation.DisplayID
}
return conversation.ID
}
func publicUnix(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
func isChatwootWidgetRoute(c *gin.Context) bool {
return strings.HasPrefix(c.FullPath(), "/api/v1/widget/")
}
func widgetConversationNotFoundLike(err error) bool {
if errors.Is(err, service.ErrWidgetConversationNotFound) {
return true
}
msg := err.Error()
return strings.Contains(msg, "invalid widget_token") || strings.Contains(msg, "conversation not found") || strings.Contains(msg, "record not found")
}
func widgetWebsiteTokenNotFoundLike(err error) bool {
msg := err.Error()
return strings.Contains(msg, "website_token is required") || strings.Contains(msg, "no inbox found for website_token") || strings.Contains(msg, "no web_widget inboxes found")
}
func widgetErrorStatus(err error) int {
if errors.Is(err, service.ErrWidgetMessageContentTooLong) {
return http.StatusUnprocessableEntity
}
msg := err.Error()
if strings.Contains(msg, "invalid widget_token") || strings.Contains(msg, "HMAC failed") {
return http.StatusUnauthorized
}
if strings.Contains(msg, "not found") {
return http.StatusNotFound
}
if strings.Contains(msg, "CSAT survey after 14 days") {
return http.StatusUnprocessableEntity
}
return http.StatusBadRequest
}