diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 62454d31..0499d378 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -20,6 +20,7 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc - Route dump succeeds with `TOTAL: 791`. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. - Tracked frontend-critical route audit covers 251 Chatwoot routes: 251 exact, 0 method-compatible, 0 parameter-compatible, 0 missing. +- `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers. - Handler test stability fixes are committed into the baseline before feature parity work continues. - `.codegraph/` is generated indexing output and is not part of tracked product code. @@ -30,10 +31,10 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc | Phase 0 | Test and route baseline | Done | none | | Phase 1 | Meilisearch search engine | Review | live Meilisearch integration and document-shape parity still need reference verification | | Phase 2 | Route and controller parity audit | Doing | Ruby/Bundler unavailable, so Chatwoot route extraction currently uses static `routes.rb` fallback | -| Phase 3 | Data and serializer parity | Planned | needs route-gap priorities from Phase 2 | +| Phase 3 | Data and serializer parity | Doing | JSON fixture coverage is partial and still endpoint-family based | | Phase 4 | Enterprise feature completion | Planned | excluded SSO family must stay out of scope; all other enterprise features remain included | | Phase 5 | Background jobs and integrations | Planned | durable worker choice and job parity are open | -| Phase 6 | Core placeholder burn-down | Planned | placeholders must be converted into real Chatwoot-compatible behavior | +| Phase 6 | Core placeholder burn-down | Doing | account/contact/conversation/message/inbox placeholder groups remain broad | | Phase 7 | Verification harness | Planned | route/JSON/frontend smoke harness not complete | ## Tracking Artifacts @@ -401,9 +402,20 @@ Tracking table: | P6.3 | Conversation APIs | `docs/ROUTE_GAP_ANALYSIS.md`, conversation handlers/services | Implement frontend-critical filters, assignment, status, snooze, merge, bulk actions. | Todo | | P6.4 | Message APIs | `docs/ROUTE_GAP_ANALYSIS.md`, message handlers/services | Implement create/list/delete, private notes, attachments, source attribution, events. | Todo | | P6.5 | Inbox APIs | `docs/ROUTE_GAP_ANALYSIS.md`, inbox handlers/services | Implement CRUD, assignable agents, avatar, campaigns, channel settings, reset secret. | Todo | -| P6.6 | Widget/public APIs | `docs/ROUTE_GAP_ANALYSIS.md`, widget/channel provider code, `chatwootParityStub` routes | Implement init/config, campaigns, events, contact, conversations, messages, cable token, direct uploads, public inbox flows, and public CSAT with Chatwoot contracts. | Doing | +| P6.6 | Widget/public APIs | `docs/ROUTE_GAP_ANALYSIS.md`, widget/channel provider code, `chatwootParityStub` routes | Finish direct uploads and deeper public CSAT parity; public inbox/contact/conversation/message core flow is now handler-backed. | Doing | | P6.7 | Webhook ingress | `internal/router/router.go`, `internal/handler/webhook/*`, channel providers | Replace generic placeholder with provider-specific verified ingestion and dispatch. | Todo | +Widget/public subtracking: + +| ID | Slice | Reference behavior | Status | +| --- | --- | --- | --- | +| P6.6a | `/api/v1/widget` config, message send/list, contact, conversations, cable token | `reference/chatwoot/app/controllers/api/v1/widget/*` and widget SDK clients | Done | +| P6.6b | `/api/v1/widget` campaigns, events, inbox members, labels | Chatwoot widget campaigns/events/labels controllers and serializers | Done | +| P6.6c | `/api/v1/widget` message update, transcript, `contact/set_user`, Dyte participant | Chatwoot widget message/contact/transcript/integration behavior | Done | +| P6.6d | `/public/api/v1/inboxes` contact/conversation/message core flow | `reference/chatwoot/app/controllers/public/api/v1/inboxes/*` and matching jbuilder views | Done | +| P6.6e | Widget direct uploads and attachments | Chatwoot active storage/direct upload and attachment payloads | Todo | +| P6.6f | Public CSAT deep behavior | Chatwoot CSAT survey controller/listener and message locking rules | Todo | + ## Phase 7: Verification Harness Status: planned. @@ -465,3 +477,4 @@ Verification milestone gates: - 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. +- 2026-06-04: Replaced `/public/api/v1/inboxes` contact/conversation/message placeholders with real Chatwoot public API handlers. API inboxes now resolve through `Channel::Api` identifiers, public contacts create/update by `source_id` with optional identifier HMAC verification, public conversations enforce the same verified-contact visibility split, and public messages support create/list/update submitted values. Added focused public API handler flow coverage. Verified `go test ./...`, regenerated `docs/parity/gochat_routes.txt` (`TOTAL: 791`), and regenerated `docs/parity/route_parity.md` (`251 exact, 0 missing`). Remaining P6.6 work is direct uploads/attachments and deeper public CSAT behavior. diff --git a/internal/handler/widget/widget_handler.go b/internal/handler/widget/widget_handler.go index ed8ffd1c..b220d3f3 100644 --- a/internal/handler/widget/widget_handler.go +++ b/internal/handler/widget/widget_handler.go @@ -735,6 +735,202 @@ func (h *WidgetHandler) AddDyteParticipantToMeeting(c *gin.Context) { c.JSON(http.StatusOK, resp) } +func (h *WidgetHandler) PublicInboxShow(c *gin.Context) { + inbox, identityValidation, err := h.widgetService.PublicGetInbox(c.Request.Context(), c.Param("inbox_id")) + if err != nil { + c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) + return + } + payload := publicInboxPayload(inbox) + payload["identifier"] = c.Param("inbox_id") + payload["identity_validation_enabled"] = identityValidation + c.JSON(http.StatusOK, payload) +} + +func (h *WidgetHandler) PublicCreateContact(c *gin.Context) { + req, err := bindPublicContactRequest(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) + return + } + resp, err := h.widgetService.PublicCreateContact(c.Request.Context(), c.Param("inbox_id"), req) + if err != nil { + c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, publicContactPayload(resp.ContactInbox, resp.Contact)) +} + +func (h *WidgetHandler) PublicGetContact(c *gin.Context) { + resp, err := h.widgetService.PublicGetContact(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id")) + if err != nil { + c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, publicContactPayload(resp.ContactInbox, resp.Contact)) +} + +func (h *WidgetHandler) PublicUpdateContact(c *gin.Context) { + req, err := bindPublicContactRequest(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) + return + } + resp, err := h.widgetService.PublicUpdateContact(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), req) + if err != nil { + c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, publicContactPayload(resp.ContactInbox, resp.Contact)) +} + +func (h *WidgetHandler) PublicListConversations(c *gin.Context) { + conversations, err := h.widgetService.PublicListConversations(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id")) + if err != nil { + c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) + return + } + payload := make([]gin.H, 0, len(conversations)) + for _, conversation := range conversations { + payload = append(payload, publicConversationPayload(conversation, nil)) + } + 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, publicConversationPayload(*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, 0, 100) + c.JSON(http.StatusOK, publicConversationPayload(*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, publicConversationPayload(*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", "25")) + messages, _, conversation, err := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, offset, 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 { + payload = append(payload, publicMessagePayload(message, *conversation)) + } + 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, 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 + } + c.JSON(http.StatusOK, publicMessagePayload(*message, *conversation)) +} + +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, @@ -863,6 +1059,137 @@ func widgetContactFullPayload(contact *model.Contact) gin.H { } } +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"` + } + 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, + }, nil +} + +func bindPublicMessageRequest(c *gin.Context) (service.PublicMessageRequest, error) { + var body struct { + Content string `json:"content"` + EchoID string `json:"echo_id"` + SubmittedValues []map[string]any `json:"submitted_values"` + } + if err := c.ShouldBindJSON(&body); err != nil { + return service.PublicMessageRequest{}, err + } + return service.PublicMessageRequest{ + Content: body.Content, + EchoID: body.EchoID, + SubmittedValues: body.SubmittedValues, + }, 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 publicConversationPayload(conversation model.Conversation, messages []model.Message) gin.H { + payload := gin.H{ + "id": publicDisplayID(conversation), + "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 { + messagePayloads = append(messagePayloads, publicMessagePayload(message, conversation)) + } + 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": message.ContentAttributes, + "created_at": message.CreatedAt.Unix(), + "conversation_id": publicDisplayID(conversation), + } + if message.SenderID != nil { + payload["sender"] = gin.H{"id": *message.SenderID, "type": message.SenderType} + } + 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 widgetErrorStatus(err error) int { msg := err.Error() if strings.Contains(msg, "invalid widget_token") || strings.Contains(msg, "HMAC failed") { diff --git a/internal/handler/widget/widget_handler_test.go b/internal/handler/widget/widget_handler_test.go index c6b0c1ac..d5372a35 100644 --- a/internal/handler/widget/widget_handler_test.go +++ b/internal/handler/widget/widget_handler_test.go @@ -22,6 +22,7 @@ import ( "github.com/gochat/gochat/internal/campaign" "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" ws "github.com/gochat/gochat/internal/ws" @@ -62,6 +63,7 @@ func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler &model.AccountUser{}, &model.InboxMember{}, &model.Tag{}, + &channelmodel.ChannelAPI{}, &campaign.Campaign{}, )) @@ -122,6 +124,34 @@ func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler chatwootWidgetGroup.POST("/integrations/dyte/add_participant_to_meeting", handler.AddDyteParticipantToMeeting) } + publicInboxes := router.Group("/public/api/v1/inboxes") + { + publicInboxes.GET("/:inbox_id", handler.PublicInboxShow) + contacts := publicInboxes.Group("/:inbox_id/contacts") + { + contacts.POST("", handler.PublicCreateContact) + contacts.GET("/:contact_id", handler.PublicGetContact) + contacts.PUT("/:contact_id", handler.PublicUpdateContact) + contacts.PATCH("/:contact_id", handler.PublicUpdateContact) + conversations := contacts.Group("/:contact_id/conversations") + { + conversations.GET("", handler.PublicListConversations) + conversations.POST("", handler.PublicCreateConversation) + conversations.GET("/:conversation_id", handler.PublicGetConversation) + conversations.POST("/:conversation_id/toggle_status", handler.PublicToggleStatus) + conversations.POST("/:conversation_id/toggle_typing", handler.PublicToggleTyping) + conversations.POST("/:conversation_id/update_last_seen", handler.PublicUpdateLastSeen) + messages := conversations.Group("/:conversation_id/messages") + { + messages.GET("", handler.PublicListMessages) + messages.POST("", handler.PublicCreateMessage) + messages.PUT("/:message_id", handler.PublicUpdateMessage) + messages.PATCH("/:message_id", handler.PublicUpdateMessage) + } + } + } + } + return db, router, handler } @@ -152,6 +182,32 @@ func seedWidgetHandlerData(t *testing.T, db *gorm.DB) (*model.Account, *model.In return account, inbox } +func seedPublicAPIInbox(t *testing.T, db *gorm.DB) (*model.Account, *model.Inbox, *channelmodel.ChannelAPI) { + t.Helper() + + account := &model.Account{Name: "PublicAPIOrg", Locale: "en", Status: "active"} + require.NoError(t, db.Create(account).Error) + + inbox := &model.Inbox{ + AccountID: account.ID, + Name: "Public API Inbox", + ChannelType: "api", + ChannelID: 1, + Enabled: true, + Timezone: "UTC", + GreetingEnabled: true, + CsatSurveyEnabled: true, + WorkingHoursEnabled: false, + AllowMessagesAfterResolved: true, + } + require.NoError(t, db.Create(inbox).Error) + + channelAPI := &channelmodel.ChannelAPI{InboxID: inbox.ID, Identifier: "public-api-inbox", HMACToken: "public_hmac_secret"} + require.NoError(t, db.Create(channelAPI).Error) + + return account, inbox, channelAPI +} + // ========== Init Handler Tests ========== func TestWidgetHandler_Init_Success(t *testing.T) { @@ -1053,3 +1109,105 @@ func TestWidgetHandler_Init_HMACInvalid(t *testing.T) { require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.NotEmpty(t, resp["widget_token"]) } + +func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox, _ := seedPublicAPIInbox(t, db) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var inboxResp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &inboxResp)) + assert.Equal(t, "public-api-inbox", inboxResp["identifier"]) + assert.Equal(t, inbox.Name, inboxResp["name"]) + assert.Equal(t, true, inboxResp["identity_validation_enabled"]) + + contactBody := map[string]any{ + "source_id": "public-source-1", + "name": "Public Visitor", + "email": "Visitor@Public.test", + "phone_number": "+15550001111", + "custom_attributes": map[string]any{ + "plan": "trial", + }, + } + contactJSON, _ := json.Marshal(contactBody) + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts", bytes.NewReader(contactJSON)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var contactResp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &contactResp)) + assert.Equal(t, "public-source-1", contactResp["source_id"]) + assert.Equal(t, "Public Visitor", contactResp["name"]) + assert.Equal(t, "visitor@public.test", contactResp["email"]) + assert.NotEmpty(t, contactResp["pubsub_token"]) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations", bytes.NewReader([]byte(`{"custom_attributes":{"topic":"sales"}}`))) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var conversationResp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &conversationResp)) + conversationID := strconv.FormatUint(uint64(conversationResp["id"].(float64)), 10) + assert.Equal(t, "open", conversationResp["status"]) + + messageBody := map[string]any{"content": "Hello from public API", "echo_id": "echo-1"} + messageJSON, _ := json.Marshal(messageBody) + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/messages", bytes.NewReader(messageJSON)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var messageResp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &messageResp)) + messageID := strconv.FormatUint(uint64(messageResp["id"].(float64)), 10) + assert.Equal(t, "Hello from public API", messageResp["content"]) + assert.Equal(t, "incoming", messageResp["message_type"]) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/messages", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var messagesResp []map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &messagesResp)) + require.Len(t, messagesResp, 1) + assert.Equal(t, "Hello from public API", messagesResp[0]["content"]) + + updateBody := map[string]any{"submitted_values": []map[string]any{{"name": "email", "value": "visitor@public.test"}}} + updateJSON, _ := json.Marshal(updateBody) + w = httptest.NewRecorder() + req, _ = http.NewRequest("PATCH", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/messages/"+messageID, bytes.NewReader(updateJSON)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &messageResp)) + attrs := messageResp["content_attributes"].(map[string]any) + assert.NotEmpty(t, attrs["submitted_values"]) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/toggle_status", nil) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/update_last_seen", nil) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/toggle_typing", bytes.NewReader([]byte(`{"typing_status":"on"}`))) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) +} diff --git a/internal/repository/inbox_repo.go b/internal/repository/inbox_repo.go index a46c1735..21ffb825 100644 --- a/internal/repository/inbox_repo.go +++ b/internal/repository/inbox_repo.go @@ -7,6 +7,7 @@ import ( "gorm.io/gorm" "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" ) // InboxRepo implements GORM repository for Inbox. @@ -145,6 +146,19 @@ func (r *InboxRepo) FindByWebsiteToken(ctx context.Context, websiteToken string) return nil, gorm.ErrRecordNotFound } +// FindAPIInboxByIdentifier resolves a public API inbox via Channel::Api identifier. +func (r *InboxRepo) FindAPIInboxByIdentifier(ctx context.Context, identifier string) (*model.Inbox, *channelmodel.ChannelAPI, error) { + var channelAPI channelmodel.ChannelAPI + if err := r.db.WithContext(ctx).Where("identifier = ?", identifier).First(&channelAPI).Error; err != nil { + return nil, nil, err + } + inbox, err := r.FindByID(ctx, channelAPI.InboxID) + if err != nil { + return nil, nil, err + } + return inbox, &channelAPI, nil +} + // parseChannelConfig parses the JSON-encoded ChannelConfig string into a map. func parseChannelConfig(inbox *model.Inbox) map[string]interface{} { if inbox.ChannelConfig == "" { @@ -176,4 +190,4 @@ func (r *InboxRepo) UpdateSecret(ctx context.Context, id uint, secret string) er func (r *InboxRepo) UpdateFields(ctx context.Context, id uint, updates map[string]interface{}) error { return r.db.WithContext(ctx).Model(&model.Inbox{}).Where("id = ?", id). Updates(updates).Error -} \ No newline at end of file +} diff --git a/internal/router/router.go b/internal/router/router.go index 437120ea..b4ce4d40 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -248,27 +248,27 @@ func RegisterRoutes( // Public API compatibility routes used by Chatwoot public inbox/contact flows. publicInboxes := publicAPI.Group("/inboxes") { - publicInboxes.GET("/:inbox_id", chatwootParityStub) + publicInboxes.GET("/:inbox_id", handlers.Widget.PublicInboxShow) contacts := publicInboxes.Group("/:inbox_id/contacts") { - contacts.POST("", chatwootParityStub) - contacts.GET("/:contact_id", chatwootParityStub) - contacts.PUT("/:contact_id", chatwootParityStub) - contacts.PATCH("/:contact_id", chatwootParityStub) + contacts.POST("", handlers.Widget.PublicCreateContact) + contacts.GET("/:contact_id", handlers.Widget.PublicGetContact) + contacts.PUT("/:contact_id", handlers.Widget.PublicUpdateContact) + contacts.PATCH("/:contact_id", handlers.Widget.PublicUpdateContact) conversations := contacts.Group("/:contact_id/conversations") { - conversations.GET("", chatwootParityStub) - conversations.POST("", chatwootParityStub) - conversations.GET("/:conversation_id", chatwootParityStub) - conversations.POST("/:conversation_id/toggle_status", chatwootParityStub) - conversations.POST("/:conversation_id/toggle_typing", chatwootParityStub) - conversations.POST("/:conversation_id/update_last_seen", chatwootParityStub) + conversations.GET("", handlers.Widget.PublicListConversations) + conversations.POST("", handlers.Widget.PublicCreateConversation) + conversations.GET("/:conversation_id", handlers.Widget.PublicGetConversation) + conversations.POST("/:conversation_id/toggle_status", handlers.Widget.PublicToggleStatus) + conversations.POST("/:conversation_id/toggle_typing", handlers.Widget.PublicToggleTyping) + conversations.POST("/:conversation_id/update_last_seen", handlers.Widget.PublicUpdateLastSeen) messages := conversations.Group("/:conversation_id/messages") { - messages.GET("", chatwootParityStub) - messages.POST("", chatwootParityStub) - messages.PUT("/:message_id", chatwootParityStub) - messages.PATCH("/:message_id", chatwootParityStub) + messages.GET("", handlers.Widget.PublicListMessages) + messages.POST("", handlers.Widget.PublicCreateMessage) + messages.PUT("/:message_id", handlers.Widget.PublicUpdateMessage) + messages.PATCH("/:message_id", handlers.Widget.PublicUpdateMessage) } } } diff --git a/internal/service/widget_service.go b/internal/service/widget_service.go index eb86d549..4a83c6ae 100644 --- a/internal/service/widget_service.go +++ b/internal/service/widget_service.go @@ -13,6 +13,7 @@ import ( "time" "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/search" ws "github.com/gochat/gochat/internal/ws" @@ -162,6 +163,32 @@ type WidgetMessageUpdate struct { SubmittedValues []map[string]any } +type PublicContactRequest struct { + SourceID string + Identifier string + IdentifierHash string + Email string + Name string + AvatarURL string + PhoneNumber string + CustomAttributes map[string]any +} + +type PublicContactResponse struct { + ContactInbox *model.ContactInbox + Contact *model.Contact +} + +type PublicConversationRequest struct { + CustomAttributes map[string]any +} + +type PublicMessageRequest struct { + Content string + EchoID string + SubmittedValues []map[string]any +} + type WidgetInboxMember struct { ID uint `json:"id"` Name string `json:"name"` @@ -547,6 +574,240 @@ func (s *WidgetService) updateContactFields(ctx context.Context, contact *model. return contact, nil } +func (s *WidgetService) PublicGetInbox(ctx context.Context, inboxIdentifier string) (*model.Inbox, bool, error) { + inbox, channelAPI, err := s.resolvePublicInbox(ctx, inboxIdentifier) + if err != nil { + return nil, false, err + } + return inbox, channelAPI.HMACToken != "", nil +} + +func (s *WidgetService) PublicCreateContact(ctx context.Context, inboxIdentifier string, req PublicContactRequest) (*PublicContactResponse, error) { + inbox, channelAPI, err := s.resolvePublicInbox(ctx, inboxIdentifier) + if err != nil { + return nil, err + } + if err := validatePublicHMAC(channelAPI.HMACToken, req.Identifier, req.IdentifierHash); err != nil { + return nil, err + } + if req.SourceID == "" { + req.SourceID, err = generateToken(16) + if err != nil { + return nil, err + } + } + if existingInbox, err := s.contactInboxRepo.FindBySourceID(ctx, inbox.ID, req.SourceID); err == nil { + contact, err := s.contactRepo.FindByID(ctx, existingInbox.ContactID) + if err != nil { + return nil, err + } + existingInbox.Contact = *contact + return &PublicContactResponse{ContactInbox: existingInbox, Contact: contact}, nil + } + contact, err := s.findPublicContact(ctx, inbox.AccountID, req) + if err != nil { + return nil, err + } + pubsubToken, err := generateToken(32) + if err != nil { + return nil, err + } + hmacToken, err := generateToken(32) + if err != nil { + return nil, err + } + contactInbox := &model.ContactInbox{ + ContactID: contact.ID, + InboxID: inbox.ID, + SourceID: req.SourceID, + PubsubToken: pubsubToken, + HMACToken: hmacToken, + HMACVerified: req.IdentifierHash != "", + } + if err := s.contactInboxRepo.Create(ctx, contactInbox); err != nil { + return nil, err + } + return &PublicContactResponse{ContactInbox: contactInbox, Contact: contact}, nil +} + +func (s *WidgetService) PublicGetContact(ctx context.Context, inboxIdentifier, sourceID string) (*PublicContactResponse, error) { + _, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID) + if err != nil { + return nil, err + } + return &PublicContactResponse{ContactInbox: contactInbox, Contact: &contactInbox.Contact}, nil +} + +func (s *WidgetService) PublicUpdateContact(ctx context.Context, inboxIdentifier, sourceID string, req PublicContactRequest) (*PublicContactResponse, error) { + channelAPI, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID) + if err != nil { + return nil, err + } + if err := validatePublicHMAC(channelAPI.HMACToken, req.Identifier, req.IdentifierHash); err != nil { + return nil, err + } + contact, err := s.updateContactFields(ctx, &contactInbox.Contact, WidgetContactUpdate{ + Name: req.Name, + Email: strings.ToLower(req.Email), + PhoneNumber: req.PhoneNumber, + Identifier: req.Identifier, + AvatarURL: req.AvatarURL, + CustomAttributes: req.CustomAttributes, + }) + if err != nil { + return nil, err + } + if req.IdentifierHash != "" && !contactInbox.HMACVerified { + contactInbox.HMACVerified = true + if err := s.contactInboxRepo.Update(ctx, contactInbox); err != nil { + return nil, err + } + } + return &PublicContactResponse{ContactInbox: contactInbox, Contact: contact}, nil +} + +func (s *WidgetService) PublicListConversations(ctx context.Context, inboxIdentifier, sourceID string) ([]model.Conversation, error) { + _, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID) + if err != nil { + return nil, err + } + conversations, _, err := s.conversationRepo.FindByContact(ctx, contactInbox.Contact.AccountID, contactInbox.ContactID, 0, 100) + if err != nil { + return nil, err + } + return filterPublicConversations(conversations, contactInbox), nil +} + +func (s *WidgetService) PublicCreateConversation(ctx context.Context, inboxIdentifier, sourceID string, req PublicConversationRequest) (*model.Conversation, error) { + _, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID) + if err != nil { + return nil, err + } + conversation, err := s.createWidgetConversation(ctx, contactInbox) + if err != nil { + return nil, err + } + if len(req.CustomAttributes) > 0 { + conversation.CustomAttributes = mustJSON(req.CustomAttributes) + if err := s.conversationRepo.Update(ctx, conversation); err != nil { + return nil, err + } + } + return conversation, nil +} + +func (s *WidgetService) PublicGetConversation(ctx context.Context, inboxIdentifier, sourceID string, displayID uint) (*model.Conversation, error) { + _, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID) + if err != nil { + return nil, err + } + conversations, err := s.PublicListConversations(ctx, inboxIdentifier, sourceID) + if err != nil { + return nil, err + } + for i := range conversations { + if publicConversationID(conversations[i]) == displayID && conversations[i].InboxID == contactInbox.InboxID { + return &conversations[i], nil + } + } + return nil, errors.New("conversation not found") +} + +func (s *WidgetService) PublicToggleStatus(ctx context.Context, inboxIdentifier, sourceID string, displayID uint) (*model.Conversation, error) { + conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID) + if err != nil { + return nil, err + } + conversation.Status = string(model.ConversationStatusResolved) + if err := s.conversationRepo.Update(ctx, conversation); err != nil { + return nil, err + } + return conversation, nil +} + +func (s *WidgetService) PublicUpdateLastSeen(ctx context.Context, inboxIdentifier, sourceID string, displayID uint) (*model.Conversation, error) { + conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID) + if err != nil { + return nil, err + } + now := time.Now().Unix() + conversation.ContactLastSeenAt = &now + if err := s.conversationRepo.Update(ctx, conversation); err != nil { + return nil, err + } + return conversation, nil +} + +func (s *WidgetService) PublicToggleTyping(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, typing bool) error { + conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID) + if err != nil { + return err + } + if s.typingIndicator == nil { + return nil + } + performer := &ws.Performer{ID: conversation.ContactID, Type: "contact"} + if typing { + return s.typingIndicator.SetTypingOn(ctx, conversation.AccountID, conversation.ID, performer) + } + return s.typingIndicator.SetTypingOff(ctx, conversation.AccountID, conversation.ID, performer) +} + +func (s *WidgetService) PublicListMessages(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, offset, limit int) ([]model.Message, int64, *model.Conversation, error) { + conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID) + if err != nil { + return nil, 0, nil, err + } + messages, total, err := s.messageRepo.FindByConversation(ctx, conversation.ID, offset, limit) + return messages, total, conversation, err +} + +func (s *WidgetService) PublicCreateMessage(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, req PublicMessageRequest) (*model.Message, *model.Conversation, error) { + if strings.TrimSpace(req.Content) == "" { + return nil, nil, errors.New("content is required") + } + conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID) + if err != nil { + return nil, nil, err + } + message := &model.Message{ + ConversationID: conversation.ID, + AccountID: conversation.AccountID, + InboxID: conversation.InboxID, + SenderID: &conversation.ContactID, + SenderType: "Contact", + Content: req.Content, + ContentType: "text", + MessageType: "incoming", + Status: "sent", + SourceID: req.EchoID, + } + if err := s.messageRepo.Create(ctx, message); err != nil { + return nil, nil, err + } + return message, conversation, nil +} + +func (s *WidgetService) PublicUpdateMessage(ctx context.Context, inboxIdentifier, sourceID string, displayID, messageID uint, req PublicMessageRequest) (*model.Message, *model.Conversation, error) { + conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID) + if err != nil { + return nil, nil, err + } + message, err := s.messageRepo.FindByConversationAndID(ctx, conversation.ID, messageID) + if err != nil { + return nil, nil, err + } + attrs := jsonMap(message.ContentAttributes) + 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 message, conversation, 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) @@ -1241,3 +1502,91 @@ func splitWidgetLabels(raw string) []string { } return labels } + +func (s *WidgetService) findPublicContact(ctx context.Context, accountID uint, req PublicContactRequest) (*model.Contact, error) { + if req.Identifier != "" { + if contact, err := s.contactRepo.FindByIdentifier(ctx, accountID, req.Identifier); err == nil { + return contact, nil + } + } + if req.Email != "" { + if contact, err := s.contactRepo.FindByEmail(ctx, accountID, strings.ToLower(req.Email)); err == nil { + return contact, nil + } + } + contact := &model.Contact{ + AccountID: accountID, + Name: req.Name, + Email: strings.ToLower(req.Email), + PhoneNumber: req.PhoneNumber, + AvatarURL: req.AvatarURL, + Identifier: req.Identifier, + ContactType: "visitor", + CustomAttributes: mustJSON(req.CustomAttributes), + } + if err := s.contactRepo.Create(ctx, contact); err != nil { + return nil, err + } + return contact, nil +} + +func (s *WidgetService) resolvePublicInbox(ctx context.Context, inboxIdentifier string) (*model.Inbox, *channelmodel.ChannelAPI, error) { + if inboxIdentifier == "" { + return nil, nil, errors.New("inbox identifier is required") + } + inbox, channelAPI, err := s.inboxRepo.FindAPIInboxByIdentifier(ctx, inboxIdentifier) + if err != nil { + return nil, nil, err + } + return inbox, channelAPI, nil +} + +func (s *WidgetService) resolvePublicContactInbox(ctx context.Context, inboxIdentifier, sourceID string) (*channelmodel.ChannelAPI, *model.ContactInbox, error) { + if sourceID == "" { + return nil, nil, errors.New("contact source_id is required") + } + _, channelAPI, err := s.resolvePublicInbox(ctx, inboxIdentifier) + if err != nil { + return nil, nil, err + } + contactInbox, err := s.contactInboxRepo.FindBySourceID(ctx, channelAPI.InboxID, sourceID) + if err != nil { + return nil, nil, err + } + contact, err := s.contactRepo.FindByID(ctx, contactInbox.ContactID) + if err != nil { + return nil, nil, err + } + contactInbox.Contact = *contact + return channelAPI, contactInbox, nil +} + +func validatePublicHMAC(hmacToken, identifier, signature string) error { + if signature == "" { + return nil + } + if !VerifyHMAC(hmacToken, identifier, signature) { + return errors.New("HMAC failed: Invalid Identifier Hash Provided") + } + return nil +} + +func filterPublicConversations(conversations []model.Conversation, contactInbox *model.ContactInbox) []model.Conversation { + if contactInbox.HMACVerified { + return conversations + } + filtered := make([]model.Conversation, 0, len(conversations)) + for _, conversation := range conversations { + if conversation.ContactInboxID != nil && *conversation.ContactInboxID == contactInbox.ID { + filtered = append(filtered, conversation) + } + } + return filtered +} + +func publicConversationID(conversation model.Conversation) uint { + if conversation.DisplayID != nil && *conversation.DisplayID != 0 { + return *conversation.DisplayID + } + return conversation.ID +}