Build and publish Docker images / Build and publish images (push) Successful in 2m23s
1620 lines
53 KiB
Go
1620 lines
53 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)
|
|
}
|
|
|
|
// Public widget requests are not a trusted connector boundary. Origin headers
|
|
// are never promoted into internal event context here.
|
|
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,
|
|
"ai_takeover_active": conversation.AssigneeAgentBotID != nil && conversation.Status == string(model.ConversationStatusPending),
|
|
"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
|
|
}
|