feat(widget): finish chatwoot widget stub burn-down

This commit is contained in:
2026-06-04 22:01:30 +08:00
parent f3b450905f
commit a0003c7c06
7 changed files with 574 additions and 42 deletions
+161 -8
View File
@@ -3,6 +3,7 @@ package widget
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -151,6 +152,44 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
c.JSON(http.StatusOK, widgetMessagePayload(resp.Message, resp.ConversationID))
}
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)
@@ -333,10 +372,13 @@ func (h *WidgetHandler) UpdateContact(c *gin.Context) {
}
var req struct {
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
PhoneNumber string `json:"phone_number,omitempty"`
CustomAttributes map[string]any `json:"custom_attributes,omitempty"`
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()})
@@ -344,10 +386,13 @@ func (h *WidgetHandler) UpdateContact(c *gin.Context) {
}
contact, err := h.widgetService.UpdateContactProfile(c.Request.Context(), widgetToken, service.WidgetContactUpdate{
Name: req.Name,
Email: req.Email,
PhoneNumber: req.PhoneNumber,
CustomAttributes: req.CustomAttributes,
Name: req.Name,
Email: req.Email,
PhoneNumber: req.PhoneNumber,
Identifier: req.Identifier,
AvatarURL: req.AvatarURL,
CustomAttributes: req.CustomAttributes,
AdditionalAttributes: req.AdditionalAttributes,
})
if err != nil {
status := http.StatusBadRequest
@@ -403,6 +448,53 @@ func (h *WidgetHandler) DestroyContactCustomAttributes(c *gin.Context) {
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
@@ -606,6 +698,43 @@ func (h *WidgetHandler) RemoveLabel(c *gin.Context) {
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 {
status := widgetErrorStatus(err)
if strings.Contains(err.Error(), "conversation not found") {
status = http.StatusTooManyRequests
}
c.JSON(status, 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"), req.MessageID)
if err != nil {
status := http.StatusUnprocessableEntity
if strings.Contains(err.Error(), "website_token") {
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
// 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,
@@ -720,3 +849,27 @@ func widgetContactPayload(contact *model.Contact) gin.H {
"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 widgetErrorStatus(err error) int {
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
}
return http.StatusBadRequest
}
@@ -15,6 +15,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -108,13 +109,17 @@ func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler
chatwootWidgetGroup.POST("/config", handler.Config)
chatwootWidgetGroup.GET("/messages", handler.GetLatestMessages)
chatwootWidgetGroup.POST("/messages", handler.SendMessage)
chatwootWidgetGroup.PATCH("/messages/:message_id", handler.UpdateMessage)
chatwootWidgetGroup.POST("/conversations", handler.CreateConversation)
chatwootWidgetGroup.POST("/conversations/transcript", handler.SendTranscript)
chatwootWidgetGroup.GET("/contact", handler.GetContact)
chatwootWidgetGroup.PATCH("/contact/set_user", handler.SetUser)
chatwootWidgetGroup.GET("/campaigns", handler.ListCampaigns)
chatwootWidgetGroup.GET("/inbox_members", handler.ListInboxMembers)
chatwootWidgetGroup.POST("/events", handler.CreateEvent)
chatwootWidgetGroup.POST("/labels", handler.AddLabel)
chatwootWidgetGroup.DELETE("/labels/:label_id", handler.RemoveLabel)
chatwootWidgetGroup.POST("/integrations/dyte/add_participant_to_meeting", handler.AddDyteParticipantToMeeting)
}
return db, router, handler
@@ -292,6 +297,149 @@ func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T)
require.Equal(t, http.StatusOK, wContact.Code)
}
func TestWidgetHandler_ChatwootMessageUpdate_SubmitsEmail(t *testing.T) {
db, router, _ := setupWidgetHandlerTest(t)
_, inbox := seedWidgetHandlerData(t, db)
wConfig := httptest.NewRecorder()
reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
router.ServeHTTP(wConfig, reqConfig)
require.Equal(t, http.StatusOK, wConfig.Code)
var configResp map[string]interface{}
require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp))
authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string)
body := map[string]interface{}{"message": map[string]interface{}{"content": "start"}}
bodyJSON, _ := json.Marshal(body)
wMessage := httptest.NewRecorder()
reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(bodyJSON))
reqMessage.Header.Set("Content-Type", "application/json")
reqMessage.Header.Set("X-Auth-Token", authToken)
router.ServeHTTP(wMessage, reqMessage)
require.Equal(t, http.StatusOK, wMessage.Code)
var conversation model.Conversation
require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error)
formMessage := &model.Message{
ConversationID: conversation.ID,
AccountID: conversation.AccountID,
InboxID: conversation.InboxID,
Content: "Email?",
ContentType: "input_email",
MessageType: "outgoing",
ContentAttributes: datatypes.JSON(`{}`),
}
require.NoError(t, db.Create(formMessage).Error)
updateBody, _ := json.Marshal(map[string]interface{}{
"contact": map[string]interface{}{"email": "Visitor@Example.TEST"},
"message": map[string]interface{}{"submitted_values": []map[string]interface{}{{"name": "email", "value": "Visitor@Example.TEST"}}},
})
wUpdate := httptest.NewRecorder()
reqUpdate, _ := http.NewRequest("PATCH", "/api/v1/widget/messages/"+strconv.FormatUint(uint64(formMessage.ID), 10), bytes.NewReader(updateBody))
reqUpdate.Header.Set("Content-Type", "application/json")
reqUpdate.Header.Set("X-Auth-Token", authToken)
router.ServeHTTP(wUpdate, reqUpdate)
require.Equal(t, http.StatusOK, wUpdate.Code)
require.NoError(t, db.First(formMessage, formMessage.ID).Error)
attrs := map[string]interface{}{}
require.NoError(t, json.Unmarshal(formMessage.ContentAttributes, &attrs))
assert.Equal(t, "visitor@example.test", attrs["submitted_email"])
var contact model.Contact
require.NoError(t, db.First(&contact, conversation.ContactID).Error)
assert.Equal(t, "visitor@example.test", contact.Email)
}
func TestWidgetHandler_ChatwootSetUserAndTranscript(t *testing.T) {
db, router, _ := setupWidgetHandlerTest(t)
_, _ = seedWidgetHandlerData(t, db)
wConfig := httptest.NewRecorder()
reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
router.ServeHTTP(wConfig, reqConfig)
require.Equal(t, http.StatusOK, wConfig.Code)
var configResp map[string]interface{}
require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp))
authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string)
hash := hmac.New(sha256.New, []byte("handler_hmac_secret"))
_, _ = hash.Write([]byte("external-123"))
identifierHash := hex.EncodeToString(hash.Sum(nil))
setUserBody, _ := json.Marshal(map[string]interface{}{
"identifier": "external-123",
"identifier_hash": identifierHash,
"email": "external@example.test",
"name": "External Visitor",
})
wSetUser := httptest.NewRecorder()
reqSetUser, _ := http.NewRequest("PATCH", "/api/v1/widget/contact/set_user?website_token=handler_ws_token_123", bytes.NewReader(setUserBody))
reqSetUser.Header.Set("Content-Type", "application/json")
reqSetUser.Header.Set("X-Auth-Token", authToken)
router.ServeHTTP(wSetUser, reqSetUser)
require.Equal(t, http.StatusOK, wSetUser.Code)
var setUserResp map[string]interface{}
require.NoError(t, json.Unmarshal(wSetUser.Body.Bytes(), &setUserResp))
assert.Equal(t, "external-123", setUserResp["identifier"])
assert.Equal(t, true, setUserResp["has_email"])
body := map[string]interface{}{"message": map[string]interface{}{"content": "Need transcript"}}
bodyJSON, _ := json.Marshal(body)
wMessage := httptest.NewRecorder()
reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(bodyJSON))
reqMessage.Header.Set("Content-Type", "application/json")
reqMessage.Header.Set("X-Auth-Token", authToken)
router.ServeHTTP(wMessage, reqMessage)
require.Equal(t, http.StatusOK, wMessage.Code)
wTranscript := httptest.NewRecorder()
reqTranscript, _ := http.NewRequest("POST", "/api/v1/widget/conversations/transcript", nil)
reqTranscript.Header.Set("X-Auth-Token", authToken)
router.ServeHTTP(wTranscript, reqTranscript)
require.Equal(t, http.StatusOK, wTranscript.Code)
}
func TestWidgetHandler_ChatwootDyteParticipant(t *testing.T) {
db, router, _ := setupWidgetHandlerTest(t)
_, inbox := seedWidgetHandlerData(t, db)
contact := &model.Contact{AccountID: inbox.AccountID, Name: "Video Visitor"}
require.NoError(t, db.Create(contact).Error)
conversation := &model.Conversation{
AccountID: inbox.AccountID,
InboxID: inbox.ID,
ContactID: contact.ID,
Status: "open",
ChannelType: inbox.ChannelType,
Channel: inbox.ChannelType,
}
require.NoError(t, db.Create(conversation).Error)
message := &model.Message{
ConversationID: conversation.ID,
AccountID: inbox.AccountID,
InboxID: inbox.ID,
Content: "Join video",
ContentType: "integrations",
MessageType: "outgoing",
ContentAttributes: datatypes.JSON(`{"data":{"meeting_id":"meeting-123"}}`),
}
require.NoError(t, db.Create(message).Error)
body, _ := json.Marshal(map[string]interface{}{"message_id": message.ID})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/widget/integrations/dyte/add_participant_to_meeting?website_token=handler_ws_token_123", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "dyte_meeting-123", resp["token"])
}
func TestWidgetHandler_ChatwootInboxMembers_Success(t *testing.T) {
db, router, _ := setupWidgetHandlerTest(t)
_, inbox := seedWidgetHandlerData(t, db)