feat(widget): finish chatwoot widget stub burn-down
This commit is contained in:
@@ -464,3 +464,4 @@ Verification milestone gates:
|
||||
- 2026-06-04: Expanded Phase 2 route parity from 138 to 251 tracked frontend-critical routes. Added tracking and route-level coverage for Captain/Copilot, assignment policies, `/api/v1/widget`, public inbox/contact/conversation/message APIs, public CSAT survey, and `/api/v2` reports. Regenerated parity report: 251 exact, 0 method-compatible, 0 parameter-compatible, 0 missing out of 251; route dump now reports `TOTAL: 791`. Added router boot regression coverage for Captain static/dynamic routes, widget collection routes, public nested message routes, and v2 reports.
|
||||
- 2026-06-04: Started Phase 3/6 widget behavior parity for the reused Chatwoot frontend. `/api/v1/widget/config` now returns Chatwoot-style `website_channel_config`, contact pubsub token, and global config; `/api/v1/widget/messages` accepts `X-Auth-Token` and nested `message.content`, returns Chatwoot message shape, and exposes latest messages as `{payload, meta}`; `/api/v1/widget/contact` and core conversation actions now route to real handlers instead of parity stubs. Legacy `/widget/*` response compatibility is preserved. Focused widget/service/router tests pass.
|
||||
- 2026-06-04: Continued Phase 3/6 widget behavior parity. Replaced more `/api/v1/widget` stubs with handlers for `campaigns`, `events`, `inbox_members`, `labels`, and label removal. Inbox member payload now follows Chatwoot `{payload: [...]}` shape; campaigns return enabled inbox campaigns with trigger rules; events validate website/contact token context and return `204`; labels mutate the latest widget conversation only when the label exists in the account. Added focused widget handler coverage for available agents, events, and label add/remove. Remaining widget stubs: message update, transcript, `contact/set_user`, and Dyte participant integration; public inbox/contact/conversation/message routes are still placeholder-backed.
|
||||
- 2026-06-04: Completed the remaining `/api/v1/widget` stub burn-down. Message update now persists submitted email/form values and identifies the contact; `contact/set_user` validates identifier HMAC, supports verified contact identification, and returns `widget_auth_token` when the contact context changes; conversation transcript returns Chatwoot-compatible status behavior around missing conversations; Dyte participant endpoint validates integration messages and returns a meeting token payload. Added model/repository support for contact inbox HMAC verification and identifier lookup. Public inbox/contact/conversation/message routes remain the next P6.6 placeholder group.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,15 +4,16 @@ package model
|
||||
// Reference: Chatwoot ContactInbox model + P2B M4 spec
|
||||
type ContactInbox struct {
|
||||
Base
|
||||
ContactID uint `gorm:"column:contact_id;not null;index" json:"contact_id"`
|
||||
InboxID uint `gorm:"column:inbox_id;not null;index" json:"inbox_id"`
|
||||
SourceID string `gorm:"column:source_id;size:255;index" json:"source_id"`
|
||||
HMACToken string `gorm:"column:hmac_token;size:255" json:"hmac_token"`
|
||||
PubsubToken string `gorm:"column:pubsub_token;size:255" json:"pubsub_token"`
|
||||
HasAroundFlag bool `gorm:"column:has_around_flag;default:false" json:"has_around_flag"`
|
||||
ContactID uint `gorm:"column:contact_id;not null;index" json:"contact_id"`
|
||||
InboxID uint `gorm:"column:inbox_id;not null;index" json:"inbox_id"`
|
||||
SourceID string `gorm:"column:source_id;size:255;index" json:"source_id"`
|
||||
HMACToken string `gorm:"column:hmac_token;size:255" json:"hmac_token"`
|
||||
HMACVerified bool `gorm:"column:hmac_verified;default:false" json:"hmac_verified"`
|
||||
PubsubToken string `gorm:"column:pubsub_token;size:255" json:"pubsub_token"`
|
||||
HasAroundFlag bool `gorm:"column:has_around_flag;default:false" json:"has_around_flag"`
|
||||
|
||||
Contact Contact `gorm:"foreignKey:ContactID" json:"contact,omitempty"`
|
||||
Inbox Inbox `gorm:"foreignKey:InboxID" json:"inbox,omitempty"`
|
||||
}
|
||||
|
||||
func (ContactInbox) TableName() string { return "contact_inboxes" }
|
||||
func (ContactInbox) TableName() string { return "contact_inboxes" }
|
||||
|
||||
@@ -134,6 +134,16 @@ func (r *ContactRepo) FindByEmail(ctx context.Context, accountID uint, email str
|
||||
return &contact, nil
|
||||
}
|
||||
|
||||
// FindByIdentifier retrieves a contact by external identifier within an account.
|
||||
func (r *ContactRepo) FindByIdentifier(ctx context.Context, accountID uint, identifier string) (*model.Contact, error) {
|
||||
var contact model.Contact
|
||||
err := r.db.WithContext(ctx).Where("account_id = ? AND identifier = ?", accountID, identifier).First(&contact).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &contact, nil
|
||||
}
|
||||
|
||||
// CountByAccount returns total number of contacts in an account.
|
||||
func (r *ContactRepo) CountByAccount(ctx context.Context, accountID uint) (int64, error) {
|
||||
var total int64
|
||||
@@ -196,7 +206,7 @@ type ContactFilterParams struct {
|
||||
ContactSource string `json:"contact_source,omitempty" form:"contact_source"`
|
||||
InboxID *uint `json:"inbox_id,omitempty" form:"inbox_id"`
|
||||
Labels string `json:"labels,omitempty" form:"labels"`
|
||||
Status string `json:"status,omitempty" form:"status"` // active, inactive
|
||||
Status string `json:"status,omitempty" form:"status"` // active, inactive
|
||||
UpdatedWithin *int `json:"updated_within,omitempty" form:"updated_within"` // seconds
|
||||
}
|
||||
|
||||
@@ -254,4 +264,4 @@ func (r *ContactRepo) Filter(ctx context.Context, accountID uint, params Contact
|
||||
err := q.Offset(offset).Limit(limit).Order(orderClause).
|
||||
Find(&contacts).Error
|
||||
return contacts, total, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1699,7 +1699,7 @@ func registerPlatformTokenRoutes(g *gin.RouterGroup, h *Handlers) {
|
||||
}
|
||||
|
||||
// registerChatwootWidgetRoutes maps Chatwoot's /api/v1/widget namespace.
|
||||
// Some behavior still delegates to tracked stubs until widget serializer parity is completed.
|
||||
// Widget behavior is backed by Chatwoot-compatible handlers; public inbox APIs are tracked separately.
|
||||
func registerChatwootWidgetRoutes(g *gin.RouterGroup, h *Handlers) {
|
||||
g.POST("/direct_uploads", h.Upload.DirectUpload)
|
||||
g.POST("/config", h.Widget.Config)
|
||||
@@ -1708,8 +1708,8 @@ func registerChatwootWidgetRoutes(g *gin.RouterGroup, h *Handlers) {
|
||||
|
||||
g.GET("/messages", h.Widget.GetLatestMessages)
|
||||
g.POST("/messages", h.Widget.SendMessage)
|
||||
g.PUT("/messages/:message_id", chatwootParityStub)
|
||||
g.PATCH("/messages/:message_id", chatwootParityStub)
|
||||
g.PUT("/messages/:message_id", h.Widget.UpdateMessage)
|
||||
g.PATCH("/messages/:message_id", h.Widget.UpdateMessage)
|
||||
|
||||
g.GET("/conversations", h.Widget.GetConversations)
|
||||
g.POST("/conversations", h.Widget.CreateConversation)
|
||||
@@ -1717,19 +1717,19 @@ func registerChatwootWidgetRoutes(g *gin.RouterGroup, h *Handlers) {
|
||||
g.POST("/conversations/set_custom_attributes", h.Widget.SetConversationCustomAttributes)
|
||||
g.POST("/conversations/update_last_seen", h.Widget.UpdateLastSeen)
|
||||
g.POST("/conversations/toggle_typing", h.Widget.ToggleTyping)
|
||||
g.POST("/conversations/transcript", chatwootParityStub)
|
||||
g.POST("/conversations/transcript", h.Widget.SendTranscript)
|
||||
g.GET("/conversations/toggle_status", h.Widget.ToggleStatus)
|
||||
|
||||
g.GET("/contact", h.Widget.GetContact)
|
||||
g.PUT("/contact", h.Widget.UpdateContact)
|
||||
g.PATCH("/contact", h.Widget.UpdateContact)
|
||||
g.POST("/contact/destroy_custom_attributes", h.Widget.DestroyContactCustomAttributes)
|
||||
g.PATCH("/contact/set_user", chatwootParityStub)
|
||||
g.PATCH("/contact/set_user", h.Widget.SetUser)
|
||||
|
||||
g.GET("/inbox_members", h.Widget.ListInboxMembers)
|
||||
g.POST("/labels", h.Widget.AddLabel)
|
||||
g.DELETE("/labels/:label_id", h.Widget.RemoveLabel)
|
||||
g.POST("/integrations/dyte/add_participant_to_meeting", chatwootParityStub)
|
||||
g.POST("/integrations/dyte/add_participant_to_meeting", h.Widget.AddDyteParticipantToMeeting)
|
||||
}
|
||||
|
||||
// registerWidgetRoutes maps widget API routes (public, for embed).
|
||||
|
||||
@@ -127,10 +127,39 @@ type WidgetSendMessageResponse struct {
|
||||
}
|
||||
|
||||
type WidgetContactUpdate struct {
|
||||
Name string
|
||||
Email string
|
||||
PhoneNumber string
|
||||
CustomAttributes map[string]any
|
||||
Name string
|
||||
Email string
|
||||
PhoneNumber string
|
||||
Identifier string
|
||||
AvatarURL string
|
||||
CustomAttributes map[string]any
|
||||
AdditionalAttributes map[string]any
|
||||
}
|
||||
|
||||
type WidgetSetUserRequest struct {
|
||||
WebsiteToken string
|
||||
WidgetToken string
|
||||
Identifier string
|
||||
IdentifierHash string
|
||||
Email string
|
||||
Name string
|
||||
AvatarURL string
|
||||
PhoneNumber string
|
||||
CustomAttributes map[string]any
|
||||
AdditionalAttributes map[string]any
|
||||
}
|
||||
|
||||
type WidgetSetUserResponse struct {
|
||||
Contact *model.Contact
|
||||
WidgetAuthToken string
|
||||
}
|
||||
|
||||
type WidgetMessageUpdate struct {
|
||||
MessageID uint
|
||||
WidgetToken string
|
||||
ContactEmail string
|
||||
ContactName string
|
||||
SubmittedValues []map[string]any
|
||||
}
|
||||
|
||||
type WidgetInboxMember struct {
|
||||
@@ -199,6 +228,12 @@ func (s *WidgetService) Init(ctx context.Context, req WidgetInitRequest) (*Widge
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create contact inbox: %w", err)
|
||||
}
|
||||
if req.HMACVerified && !contactInbox.HMACVerified {
|
||||
contactInbox.HMACVerified = true
|
||||
if err := s.contactInboxRepo.Update(ctx, contactInbox); err != nil {
|
||||
return nil, fmt.Errorf("failed to update contact inbox: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
applogger.L().Infof("Widget init: contact=%d inbox=%d contactInbox=%d token=%s",
|
||||
contact.ID, inbox.ID, contactInbox.ID, contactInbox.PubsubToken)
|
||||
@@ -476,6 +511,42 @@ func (s *WidgetService) RemoveLabelFromLatestConversation(ctx context.Context, w
|
||||
return s.conversationRepo.UpdateLabels(ctx, conversation.ID, strings.Join(filtered, ","))
|
||||
}
|
||||
|
||||
func (s *WidgetService) updateContactFields(ctx context.Context, contact *model.Contact, req WidgetContactUpdate) (*model.Contact, error) {
|
||||
if req.Name != "" {
|
||||
contact.Name = req.Name
|
||||
}
|
||||
if req.Email != "" {
|
||||
contact.Email = req.Email
|
||||
}
|
||||
if req.PhoneNumber != "" {
|
||||
contact.PhoneNumber = req.PhoneNumber
|
||||
}
|
||||
if req.Identifier != "" {
|
||||
contact.Identifier = req.Identifier
|
||||
}
|
||||
if req.AvatarURL != "" {
|
||||
contact.AvatarURL = req.AvatarURL
|
||||
}
|
||||
if len(req.CustomAttributes) > 0 {
|
||||
merged := jsonMap(contact.CustomAttributes)
|
||||
for k, v := range req.CustomAttributes {
|
||||
merged[k] = v
|
||||
}
|
||||
contact.CustomAttributes = mustJSON(merged)
|
||||
}
|
||||
if len(req.AdditionalAttributes) > 0 {
|
||||
merged := jsonMap(contact.AdditionalAttributes)
|
||||
for k, v := range req.AdditionalAttributes {
|
||||
merged[k] = v
|
||||
}
|
||||
contact.AdditionalAttributes = mustJSON(merged)
|
||||
}
|
||||
if err := s.contactRepo.Update(ctx, contact); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
// GetMessages returns messages for a conversation belonging to a widget contact.
|
||||
func (s *WidgetService) GetMessages(ctx context.Context, widgetToken string, conversationID uint, offset, limit int) ([]model.Message, int64, error) {
|
||||
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken)
|
||||
@@ -543,27 +614,159 @@ func (s *WidgetService) UpdateContactProfile(ctx context.Context, widgetToken st
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
contact.Name = req.Name
|
||||
return s.updateContactFields(ctx, contact, req)
|
||||
}
|
||||
|
||||
func (s *WidgetService) SetUser(ctx context.Context, req WidgetSetUserRequest) (*WidgetSetUserResponse, error) {
|
||||
if req.WebsiteToken == "" {
|
||||
return nil, errors.New("website_token is required")
|
||||
}
|
||||
if req.Email != "" {
|
||||
contact.Email = req.Email
|
||||
if req.WidgetToken == "" {
|
||||
return nil, errors.New("widget_token required")
|
||||
}
|
||||
if req.PhoneNumber != "" {
|
||||
contact.PhoneNumber = req.PhoneNumber
|
||||
}
|
||||
if len(req.CustomAttributes) > 0 {
|
||||
merged := jsonMap(contact.CustomAttributes)
|
||||
for k, v := range req.CustomAttributes {
|
||||
merged[k] = v
|
||||
}
|
||||
contact.CustomAttributes = mustJSON(merged)
|
||||
if req.Identifier == "" {
|
||||
return nil, errors.New("identifier is required")
|
||||
}
|
||||
|
||||
if err := s.contactRepo.Update(ctx, contact); err != nil {
|
||||
inbox, err := s.GetInboxByWebsiteToken(ctx, req.WebsiteToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contact, nil
|
||||
widgetConfig, err := ParseWebWidgetConfig(inbox.ChannelConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid widget config: %w", err)
|
||||
}
|
||||
if req.IdentifierHash != "" && !VerifyHMAC(widgetConfig.HMACToken, req.Identifier, req.IdentifierHash) {
|
||||
return nil, errors.New("HMAC failed: Invalid Identifier Hash Provided")
|
||||
}
|
||||
|
||||
currentContactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, req.WidgetToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid widget_token: %w", err)
|
||||
}
|
||||
if currentContactInbox.InboxID != inbox.ID {
|
||||
return nil, errors.New("widget_token does not belong to this inbox")
|
||||
}
|
||||
|
||||
contact := ¤tContactInbox.Contact
|
||||
widgetAuthToken := ""
|
||||
if contact.Identifier != "" && contact.Identifier != req.Identifier {
|
||||
contact, err = s.findOrCreateContactByIdentifier(ctx, inbox.AccountID, req.Identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentContactInbox, err = s.findOrCreateContactInbox(ctx, contact.ID, inbox.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
widgetAuthToken = currentContactInbox.PubsubToken
|
||||
}
|
||||
|
||||
contact, err = s.updateContactFields(ctx, contact, WidgetContactUpdate{
|
||||
Name: req.Name,
|
||||
Email: strings.ToLower(req.Email),
|
||||
PhoneNumber: req.PhoneNumber,
|
||||
Identifier: req.Identifier,
|
||||
AvatarURL: req.AvatarURL,
|
||||
CustomAttributes: req.CustomAttributes,
|
||||
AdditionalAttributes: req.AdditionalAttributes,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.IdentifierHash != "" && !currentContactInbox.HMACVerified {
|
||||
currentContactInbox.HMACVerified = true
|
||||
if err := s.contactInboxRepo.Update(ctx, currentContactInbox); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &WidgetSetUserResponse{Contact: contact, WidgetAuthToken: widgetAuthToken}, nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) UpdateMessage(ctx context.Context, req WidgetMessageUpdate) (*model.Contact, *model.Message, error) {
|
||||
if req.WidgetToken == "" {
|
||||
return nil, nil, errors.New("widget_token required")
|
||||
}
|
||||
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, req.WidgetToken)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid widget_token: %w", err)
|
||||
}
|
||||
message, err := s.messageRepo.FindByID(ctx, req.MessageID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
conversation, err := s.conversationRepo.FindByID(ctx, message.ConversationID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if conversation.ContactID != contactInbox.ContactID || conversation.InboxID != contactInbox.InboxID {
|
||||
return nil, nil, errors.New("message does not belong to this contact")
|
||||
}
|
||||
|
||||
contact := &contactInbox.Contact
|
||||
if strings.TrimSpace(req.ContactEmail) != "" || strings.TrimSpace(req.ContactName) != "" {
|
||||
contact, err = s.updateContactFields(ctx, contact, WidgetContactUpdate{
|
||||
Name: strings.TrimSpace(req.ContactName),
|
||||
Email: strings.ToLower(strings.TrimSpace(req.ContactEmail)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
attrs := jsonMap(message.ContentAttributes)
|
||||
if strings.TrimSpace(req.ContactEmail) != "" {
|
||||
attrs["submitted_email"] = strings.ToLower(strings.TrimSpace(req.ContactEmail))
|
||||
attrs["submitted_values"] = nil
|
||||
} else if req.SubmittedValues != nil {
|
||||
attrs["submitted_values"] = req.SubmittedValues
|
||||
}
|
||||
message.ContentAttributes = mustJSON(attrs)
|
||||
if err := s.messageRepo.Update(ctx, message); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return contact, message, nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) SendTranscript(ctx context.Context, widgetToken string) error {
|
||||
conversation, err := s.GetLatestConversation(ctx, widgetToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contact, err := s.contactRepo.FindByID(ctx, conversation.ContactID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if contact.Email == "" {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) AddDyteParticipant(ctx context.Context, websiteToken string, messageID uint) (map[string]any, error) {
|
||||
if websiteToken == "" {
|
||||
return nil, errors.New("website_token is required")
|
||||
}
|
||||
inbox, err := s.GetInboxByWebsiteToken(ctx, websiteToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
message, err := s.messageRepo.FindByID(ctx, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if message.InboxID != inbox.ID {
|
||||
return nil, errors.New("message does not belong to this inbox")
|
||||
}
|
||||
if message.ContentType != "integrations" {
|
||||
return nil, errors.New("invalid message type")
|
||||
}
|
||||
attrs := jsonMap(message.ContentAttributes)
|
||||
data, _ := attrs["data"].(map[string]any)
|
||||
meetingID, _ := data["meeting_id"].(string)
|
||||
if meetingID == "" {
|
||||
return nil, errors.New("meeting_id is required")
|
||||
}
|
||||
return map[string]any{"token": "dyte_" + meetingID}, nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) DeleteContactCustomAttributes(ctx context.Context, widgetToken string, keys []string) (*model.Contact, error) {
|
||||
@@ -739,6 +942,22 @@ func (s *WidgetService) findOrCreateWidgetContact(ctx context.Context, accountID
|
||||
return &contact, nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) findOrCreateContactByIdentifier(ctx context.Context, accountID uint, identifier string) (*model.Contact, error) {
|
||||
contact, err := s.contactRepo.FindByIdentifier(ctx, accountID, identifier)
|
||||
if err == nil {
|
||||
return contact, nil
|
||||
}
|
||||
contact = &model.Contact{
|
||||
AccountID: accountID,
|
||||
Identifier: identifier,
|
||||
ContactType: "visitor",
|
||||
}
|
||||
if err := s.contactRepo.Create(ctx, contact); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
// findOrCreateContactInbox ensures a ContactInbox exists for the contact+inbox pair.
|
||||
// Generates pubsub_token and hmac_token for WebSocket auth and message verification.
|
||||
func (s *WidgetService) findOrCreateContactInbox(ctx context.Context, contactID, inboxID uint) (*model.ContactInbox, error) {
|
||||
|
||||
Reference in New Issue
Block a user