diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index ed5e3fb9..62454d31 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -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. diff --git a/internal/handler/widget/widget_handler.go b/internal/handler/widget/widget_handler.go index 938e49e1..ed8ffd1c 100644 --- a/internal/handler/widget/widget_handler.go +++ b/internal/handler/widget/widget_handler.go @@ -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 +} diff --git a/internal/handler/widget/widget_handler_test.go b/internal/handler/widget/widget_handler_test.go index 55aa5f2c..c6b0c1ac 100644 --- a/internal/handler/widget/widget_handler_test.go +++ b/internal/handler/widget/widget_handler_test.go @@ -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) diff --git a/internal/model/contact_inbox.go b/internal/model/contact_inbox.go index 4eaff924..48597243 100644 --- a/internal/model/contact_inbox.go +++ b/internal/model/contact_inbox.go @@ -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" } \ No newline at end of file +func (ContactInbox) TableName() string { return "contact_inboxes" } diff --git a/internal/repository/contact_repo.go b/internal/repository/contact_repo.go index 7a56b087..7f72fc41 100644 --- a/internal/repository/contact_repo.go +++ b/internal/repository/contact_repo.go @@ -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 -} \ No newline at end of file +} diff --git a/internal/router/router.go b/internal/router/router.go index 27790d10..437120ea 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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). diff --git a/internal/service/widget_service.go b/internal/service/widget_service.go index e48884af..eb86d549 100644 --- a/internal/service/widget_service.go +++ b/internal/service/widget_service.go @@ -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) {