From 67a6ae762e46f135c246e667660c5a25fdd06cf2 Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 4 Jun 2026 21:28:29 +0800 Subject: [PATCH] feat(widget): implement chatwoot widget core handlers --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 11 +- internal/handler/widget/widget_handler.go | 391 ++++++++++++++++-- .../handler/widget/widget_handler_test.go | 86 +++- internal/router/router.go | 20 +- internal/service/widget_service.go | 218 ++++++++-- 5 files changed, 649 insertions(+), 77 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 3a481187..4fb0e92a 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -57,7 +57,7 @@ This is the ordered queue for the next implementation slices. Do not skip the ro | Q1 | Expand tracked route parity to Captain/Copilot, assignment policies, widget, public inbox/contact/conversation, public CSAT, v2 reports, summary reports, and live reports. | Phase 2 | `cmd/route_parity` tracks these groups and `docs/parity/route_parity.md` lists every missing/mismatched route. | Done | | Q2 | Patch route aliases discovered by Q1, especially Chatwoot widget/public paths such as `/api/v1/widget/...` versus existing `/widget/...`. | Phase 2 | Missing tracked routes return to zero or are explicitly documented with implementation tasks. | Done | | Q3 | Add route boot regression coverage for wildcard conflict groups before expanding more Rails-style resources. | Phase 2 | Router tests cover nested dynamic resources that previously risked Gin conflicts. | Done | -| Q4 | Start serializer parity fixtures for auth/session, conversations/messages, contacts/companies, inboxes, notifications, and search. | Phase 3 | Each area has at least one reference fixture and Go response test. | Next | +| Q4 | Start serializer parity fixtures for auth/session, conversations/messages, contacts/companies, inboxes, notifications, and search. | Phase 3 | Each area has at least one reference fixture and Go response test. | Doing | | Q5 | Review Meilisearch document shape and endpoint payloads against Chatwoot frontend consumers. | Phase 1 and Phase 3 | Search remains Meilisearch-first and payload mismatches are fixed or tracked. | Todo | | Q6 | Burn down enterprise gaps in this order: SLA, assignment policy and capacity, CSAT, automation/macros, Audit, CustomRole, InboxLimit, Captain/Copilot. | Phase 4 and Phase 5 | Each feature passes route, persistence, auth, side-effect, response, and test checks. | Todo | | Q7 | Add frontend smoke harness using the reused Chatwoot frontend once core API flows boot end-to-end. | Phase 7 | Login, inbox list, conversation list/detail, message send, contact view, and widget init run without frontend adapters. | Todo | @@ -264,7 +264,7 @@ Frontend-critical API groups to audit first: | P3.5 | Contacts/companies | CRUD, merge, labels, notes, custom attributes, import/export, conversations relation. | Todo | | P3.6 | Labels/custom attributes/custom filters | Create/update/list behavior and exact response shapes. | Todo | | P3.7 | Notifications/reports/help center/campaigns | Frontend-visible payloads and pagination/error envelopes. | Todo | -| P3.8 | Widget/public APIs | Widget init, campaigns, config, contact, conversations, messages, direct uploads, public inbox flow, public CSAT. | Todo | +| P3.8 | Widget/public APIs | Widget init, campaigns, config, contact, conversations, messages, direct uploads, public inbox flow, public CSAT. | Doing | | P3.9 | Search payloads | Global search and entity search documents backed by Meilisearch. | Review | Serializer parity work plan: @@ -277,7 +277,7 @@ Serializer parity work plan: | S4 | Inboxes and channels | `reference/chatwoot/app/controllers/api/v1/accounts/inboxes*`, channel controllers | fixture tests for inbox CRUD, channel settings, widget config | Todo | | S5 | Notifications and settings | `reference/chatwoot/app/controllers/api/v1/accounts/notifications*` | fixture tests for notification list/actions/settings | Todo | | S6 | Reports and CSAT | `reference/chatwoot/app/controllers/api/v1/accounts/reports*`, `csat_survey_responses*` | fixture tests for report filters and CSAT metrics/list | Todo | -| S7 | Widget/public | `reference/chatwoot/app/controllers/api/v1/widget*`, `public/api/v1*` | widget smoke fixtures and public flow tests | Todo | +| S7 | Widget/public | `reference/chatwoot/app/controllers/api/v1/widget*`, `public/api/v1*` | widget smoke fixtures and public flow tests | Doing | | S8 | Search | `reference/chatwoot` search controllers plus frontend search client | Meilisearch-backed search response fixtures | Todo | Serializer comparison rules: @@ -288,7 +288,7 @@ Serializer comparison rules: ## Phase 4: Enterprise Feature Completion -Status: planned. +Status: doing. Excluded: @@ -401,7 +401,7 @@ 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. | 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.7 | Webhook ingress | `internal/router/router.go`, `internal/handler/webhook/*`, channel providers | Replace generic placeholder with provider-specific verified ingestion and dispatch. | Todo | ## Phase 7: Verification Harness @@ -462,3 +462,4 @@ Verification milestone gates: - 2026-06-04: First tracked Phase 2 route gaps closed. Added Chatwoot-compatible contact conversations, contact export POST, conversation `toggle_priority`, and PUT aliases for conversation/message updates; fixed contact handlers to accept `:account_id` as well as legacy `:id`. Regenerated parity report: 81 exact, 0 method-compatible, 0 parameter-compatible, 0 missing out of 81; route dump now reports `TOTAL: 709`. - 2026-06-04: Expanded Phase 2 route parity from 81 to 138 tracked frontend-critical account routes. Added account-scoped notification routes, `notification_settings` PUT alias, and Chatwoot account-level `inbox_members` create/show/update/destroy handlers. Regenerated parity report: 138 exact, 0 method-compatible, 0 parameter-compatible, 0 missing out of 138; route dump now reports `TOTAL: 723`. - 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. diff --git a/internal/handler/widget/widget_handler.go b/internal/handler/widget/widget_handler.go index 798fda9c..5bb96f6d 100644 --- a/internal/handler/widget/widget_handler.go +++ b/internal/handler/widget/widget_handler.go @@ -32,10 +32,13 @@ func NewHandler(widgetService *service.WidgetService) *WidgetHandler { // contact attributes are optional (anonymous visitor if not provided). func (h *WidgetHandler) Init(c *gin.Context) { var req service.WidgetInitRequest - if err := c.ShouldBindJSON(&req); err != nil { + if err := c.ShouldBindJSON(&req); err != nil && c.Request.ContentLength != 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } + if req.WebsiteToken == "" { + req.WebsiteToken = c.Query("website_token") + } // HMAC verification: if the client provides an identifier + identifier_hash, // verify the hash against the inbox's hmac_token. This mirrors Chatwoot's @@ -72,21 +75,60 @@ func (h *WidgetHandler) Init(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// Config implements Chatwoot's POST /api/v1/widget/config endpoint. +func (h *WidgetHandler) Config(c *gin.Context) { + var req service.WidgetInitRequest + if err := c.ShouldBindJSON(&req); err != nil && c.Request.ContentLength != 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) + return + } + if req.WebsiteToken == "" { + req.WebsiteToken = c.Query("website_token") + } + + resp, err := h.widgetService.Init(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + contact := gin.H{ + "id": resp.ContactID, + "pubsub_token": resp.WidgetToken, + } + if resp.Contact != nil { + contact["email"] = resp.Contact.Email + contact["identifier"] = resp.Contact.Identifier + contact["name"] = resp.Contact.Name + contact["phone_number"] = resp.Contact.PhoneNumber + } + + c.JSON(http.StatusOK, gin.H{ + "website_channel_config": gin.H{ + "auth_token": resp.WidgetToken, + "website_token": resp.WidgetConfig.WebsiteToken, + "widget_color": resp.WidgetConfig.WidgetColor, + "welcome_title": resp.WidgetConfig.WelcomeTitle, + "welcome_tagline": resp.WidgetConfig.WelcomeTagline, + "website_name": resp.InboxName, + }, + "contact": contact, + "global_config": gin.H{}, + }) +} + // SendMessage sends a message from the widget contact to the conversation. // POST /widget/messages // Reference: Chatwoot widget SDK — send message endpoint func (h *WidgetHandler) SendMessage(c *gin.Context) { - widgetToken := c.GetHeader("X-Widget-Token") - if widgetToken == "" { - widgetToken = c.Query("widget_token") - } + widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } - var req service.WidgetSendMessageRequest - if err := c.ShouldBindJSON(&req); err != nil { + req, err := bindWidgetSendMessageRequest(c) + if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } @@ -102,17 +144,53 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) { return } - c.JSON(http.StatusOK, resp) + if c.FullPath() == "/widget/messages" { + c.JSON(http.StatusOK, resp) + return + } + c.JSON(http.StatusOK, widgetMessagePayload(resp.Message, resp.ConversationID)) +} + +// GetLatestMessages implements Chatwoot's GET /api/v1/widget/messages endpoint. +func (h *WidgetHandler) GetLatestMessages(c *gin.Context) { + widgetToken := widgetTokenFromRequest(c) + if widgetToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) + return + } + + offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "25")) + if limit <= 0 || limit > 100 { + limit = 25 + } + + messages, total, conversation, err := h.widgetService.GetLatestConversationMessages(c.Request.Context(), widgetToken, offset, limit) + if err != nil { + status := http.StatusBadRequest + if err.Error() == "invalid widget_token" { + status = http.StatusUnauthorized + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + + payload := make([]gin.H, 0, len(messages)) + for _, msg := range messages { + payload = append(payload, widgetMessagePayload(msg, msg.ConversationID)) + } + meta := gin.H{"total": total, "offset": offset, "limit": limit} + if conversation != nil && conversation.ContactLastSeenAt != nil { + meta["contact_last_seen_at"] = *conversation.ContactLastSeenAt + } + c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": meta}) } // GetConversations returns the conversation list for a widget contact. // GET /widget/conversations // Reference: Chatwoot widget SDK — fetch conversation list func (h *WidgetHandler) GetConversations(c *gin.Context) { - widgetToken := c.GetHeader("X-Widget-Token") - if widgetToken == "" { - widgetToken = c.Query("widget_token") - } + widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return @@ -128,17 +206,60 @@ func (h *WidgetHandler) GetConversations(c *gin.Context) { return } + if c.FullPath() == "/api/v1/widget/conversations" { + if len(conversations) == 0 { + c.JSON(http.StatusOK, gin.H{}) + return + } + conversation := conversations[0] + c.JSON(http.StatusOK, widgetConversationPayload(conversation)) + return + } + c.JSON(http.StatusOK, gin.H{"conversations": conversations}) } +// CreateConversation implements Chatwoot's POST /api/v1/widget/conversations endpoint. +func (h *WidgetHandler) CreateConversation(c *gin.Context) { + widgetToken := widgetTokenFromRequest(c) + if widgetToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) + return + } + + req, err := bindWidgetSendMessageRequest(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) + return + } + req.WidgetToken = widgetToken + req.ConversationID = nil + + resp, err := h.widgetService.SendMessage(c.Request.Context(), req) + if err != nil { + status := http.StatusBadRequest + if err.Error() == "invalid widget_token" || err.Error() == "widget_token required" { + status = http.StatusUnauthorized + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + + conversation, err := h.widgetService.GetConversation(c.Request.Context(), widgetToken, resp.ConversationID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"id": resp.ConversationID, "messages": []gin.H{widgetMessagePayload(resp.Message, resp.ConversationID)}}) + return + } + payload := widgetConversationPayload(*conversation) + payload["messages"] = []gin.H{widgetMessagePayload(resp.Message, resp.ConversationID)} + c.JSON(http.StatusOK, payload) +} + // GetMessages retrieves messages for a specific conversation. // GET /widget/conversations/:id/messages // Reference: Chatwoot widget SDK — fetch messages for a conversation func (h *WidgetHandler) GetMessages(c *gin.Context) { - widgetToken := c.GetHeader("X-Widget-Token") - if widgetToken == "" { - widgetToken = c.Query("widget_token") - } + widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return @@ -182,10 +303,7 @@ func (h *WidgetHandler) GetMessages(c *gin.Context) { // GET /widget/cable_token // Reference: Chatwoot widget SDK — fetch token for ActionCable subscription func (h *WidgetHandler) GetCableToken(c *gin.Context) { - widgetToken := c.GetHeader("X-Widget-Token") - if widgetToken == "" { - widgetToken = c.Query("widget_token") - } + widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return @@ -208,25 +326,29 @@ func (h *WidgetHandler) GetCableToken(c *gin.Context) { // PATCH /widget/contact // Reference: Chatwoot widget SDK — update contact name/email func (h *WidgetHandler) UpdateContact(c *gin.Context) { - widgetToken := c.GetHeader("X-Widget-Token") - if widgetToken == "" { - widgetToken = c.Query("widget_token") - } + widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } var req struct { - Name string `json:"name,omitempty"` - Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` + CustomAttributes map[string]any `json:"custom_attributes,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } - contact, err := h.widgetService.UpdateContact(c.Request.Context(), widgetToken, req.Name, req.Email) + contact, err := h.widgetService.UpdateContactProfile(c.Request.Context(), widgetToken, service.WidgetContactUpdate{ + Name: req.Name, + Email: req.Email, + PhoneNumber: req.PhoneNumber, + CustomAttributes: req.CustomAttributes, + }) if err != nil { status := http.StatusBadRequest if err.Error() == "invalid widget_token" { @@ -236,6 +358,48 @@ func (h *WidgetHandler) UpdateContact(c *gin.Context) { return } + c.JSON(http.StatusOK, widgetContactPayload(contact)) +} + +// GetContact implements Chatwoot's GET /api/v1/widget/contact endpoint. +func (h *WidgetHandler) GetContact(c *gin.Context) { + widgetToken := widgetTokenFromRequest(c) + if widgetToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) + return + } + + contact, err := h.widgetService.GetContact(c.Request.Context(), widgetToken) + if err != nil { + status := http.StatusBadRequest + if err.Error() == "invalid widget_token" { + status = http.StatusUnauthorized + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, widgetContactPayload(contact)) +} + +func (h *WidgetHandler) DestroyContactCustomAttributes(c *gin.Context) { + widgetToken := widgetTokenFromRequest(c) + if widgetToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) + return + } + + var req struct { + CustomAttributes []string `json:"custom_attributes"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) + return + } + contact, err := h.widgetService.DeleteContactCustomAttributes(c.Request.Context(), widgetToken, req.CustomAttributes) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusOK, contact) } @@ -243,29 +407,43 @@ func (h *WidgetHandler) UpdateContact(c *gin.Context) { // POST /widget/conversations/:id/toggle_typing // Reference: Chatwoot widget SDK — typing indicator func (h *WidgetHandler) ToggleTyping(c *gin.Context) { - widgetToken := c.GetHeader("X-Widget-Token") - if widgetToken == "" { - widgetToken = c.Query("widget_token") - } + widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } + var conversationID uint64 + var err error conversationIDStr := c.Param("id") - conversationID, err := strconv.ParseUint(conversationIDStr, 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid conversation id"}) - return + if conversationIDStr != "" { + conversationID, err = strconv.ParseUint(conversationIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid conversation id"}) + return + } + } else { + conversation, convErr := h.widgetService.GetLatestConversation(c.Request.Context(), widgetToken) + if convErr != nil { + c.JSON(http.StatusNotFound, gin.H{"error": convErr.Error()}) + return + } + conversationID = uint64(conversation.ID) } var req struct { - Typing bool `json:"typing"` + Typing bool `json:"typing"` + TypingStatus string `json:"typing_status"` } if err := c.ShouldBindJSON(&req); err != nil { // Default to typing=true if no body provided req.Typing = true } + if req.TypingStatus == "on" { + req.Typing = true + } else if req.TypingStatus == "off" { + req.Typing = false + } err = h.widgetService.ToggleTyping(c.Request.Context(), widgetToken, uint(conversationID), req.Typing) if err != nil { @@ -280,6 +458,76 @@ func (h *WidgetHandler) ToggleTyping(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } +func (h *WidgetHandler) UpdateLastSeen(c *gin.Context) { + widgetToken := widgetTokenFromRequest(c) + if widgetToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) + return + } + conversation, err := h.widgetService.UpdateLastSeen(c.Request.Context(), widgetToken) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"contact_last_seen_at": conversation.ContactLastSeenAt}) +} + +func (h *WidgetHandler) ToggleStatus(c *gin.Context) { + widgetToken := widgetTokenFromRequest(c) + if widgetToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) + return + } + conversation, err := h.widgetService.ResolveLatestConversation(c.Request.Context(), widgetToken) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, widgetConversationPayload(*conversation)) +} + +func (h *WidgetHandler) SetConversationCustomAttributes(c *gin.Context) { + widgetToken := widgetTokenFromRequest(c) + if widgetToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) + return + } + var req struct { + CustomAttributes map[string]any `json:"custom_attributes"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) + return + } + conversation, err := h.widgetService.SetLatestConversationCustomAttributes(c.Request.Context(), widgetToken, req.CustomAttributes) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, conversation) +} + +func (h *WidgetHandler) DestroyConversationCustomAttributes(c *gin.Context) { + widgetToken := widgetTokenFromRequest(c) + if widgetToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) + return + } + var req struct { + CustomAttribute []string `json:"custom_attribute"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) + return + } + conversation, err := h.widgetService.DeleteLatestConversationCustomAttributes(c.Request.Context(), widgetToken, req.CustomAttribute) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, 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, @@ -324,4 +572,73 @@ func (h *WidgetHandler) SubmitOfflineMessage(c *gin.Context) { "offline_message": msg, "message": "Your message has been recorded. An agent will respond when available.", }) -} \ No newline at end of file +} + +func widgetTokenFromRequest(c *gin.Context) string { + if token := c.GetHeader("X-Widget-Token"); token != "" { + return token + } + if token := c.GetHeader("X-Auth-Token"); token != "" { + return token + } + return c.Query("widget_token") +} + +func bindWidgetSendMessageRequest(c *gin.Context) (service.WidgetSendMessageRequest, error) { + var body struct { + Content string `json:"content"` + ContentType string `json:"content_type"` + ConversationID *uint `json:"conversation_id"` + Message struct { + Content string `json:"content"` + } `json:"message"` + } + if err := c.ShouldBindJSON(&body); err != nil { + return service.WidgetSendMessageRequest{}, err + } + content := body.Content + if content == "" { + content = body.Message.Content + } + return service.WidgetSendMessageRequest{ + Content: content, + ContentType: body.ContentType, + ConversationID: body.ConversationID, + }, nil +} + +func widgetMessagePayload(message model.Message, conversationID uint) gin.H { + return gin.H{ + "id": message.ID, + "content": message.Content, + "inbox_id": message.InboxID, + "conversation_id": conversationID, + "message_type": message.MessageType, + "content_type": message.ContentType, + "content_attributes": message.ContentAttributes, + "created_at": message.CreatedAt.Unix(), + "private": message.Private, + "source_id": message.SourceID, + } +} + +func widgetConversationPayload(conversation model.Conversation) gin.H { + return gin.H{ + "id": conversation.ID, + "uuid": conversation.UUID, + "inbox_id": conversation.InboxID, + "contact_last_seen_at": conversation.ContactLastSeenAt, + "status": conversation.Status, + "custom_attributes": conversation.CustomAttributes, + } +} + +func widgetContactPayload(contact *model.Contact) gin.H { + return gin.H{ + "id": contact.ID, + "identifier": contact.Identifier, + "has_email": contact.Email != "", + "has_name": contact.Name != "", + "has_phone_number": contact.PhoneNumber != "", + } +} diff --git a/internal/handler/widget/widget_handler_test.go b/internal/handler/widget/widget_handler_test.go index 28aae662..c64c6a95 100644 --- a/internal/handler/widget/widget_handler_test.go +++ b/internal/handler/widget/widget_handler_test.go @@ -94,6 +94,15 @@ func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler widgetGroup.POST("/offline_message", handler.SubmitOfflineMessage) } + chatwootWidgetGroup := router.Group("/api/v1/widget") + { + chatwootWidgetGroup.POST("/config", handler.Config) + chatwootWidgetGroup.GET("/messages", handler.GetLatestMessages) + chatwootWidgetGroup.POST("/messages", handler.SendMessage) + chatwootWidgetGroup.POST("/conversations", handler.CreateConversation) + chatwootWidgetGroup.GET("/contact", handler.GetContact) + } + return db, router, handler } @@ -194,6 +203,81 @@ func TestWidgetHandler_Init_InvalidWebsiteToken(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) } +func TestWidgetHandler_ChatwootConfig_Success(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, _ = seedWidgetHandlerData(t, db) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + + config := resp["website_channel_config"].(map[string]interface{}) + assert.NotEmpty(t, config["auth_token"]) + assert.Equal(t, "handler_ws_token_123", config["website_token"]) + assert.Equal(t, "Handler Widget Inbox", config["website_name"]) + + contact := resp["contact"].(map[string]interface{}) + assert.NotEmpty(t, contact["id"]) + assert.NotEmpty(t, contact["pubsub_token"]) +} + +func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(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)) + contact := configResp["contact"].(map[string]interface{}) + authToken := contact["pubsub_token"].(string) + + body := map[string]interface{}{ + "message": map[string]interface{}{"content": "Hello from Chatwoot widget"}, + } + 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 messageResp map[string]interface{} + require.NoError(t, json.Unmarshal(wMessage.Body.Bytes(), &messageResp)) + assert.Nil(t, messageResp["message"]) + assert.Equal(t, "Hello from Chatwoot widget", messageResp["content"]) + assert.NotEmpty(t, messageResp["conversation_id"]) + + wIndex := httptest.NewRecorder() + reqIndex, _ := http.NewRequest("GET", "/api/v1/widget/messages", nil) + reqIndex.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wIndex, reqIndex) + require.Equal(t, http.StatusOK, wIndex.Code) + + var indexResp map[string]interface{} + require.NoError(t, json.Unmarshal(wIndex.Body.Bytes(), &indexResp)) + payload := indexResp["payload"].([]interface{}) + require.Len(t, payload, 1) + firstMessage := payload[0].(map[string]interface{}) + assert.Equal(t, "Hello from Chatwoot widget", firstMessage["content"]) + + wContact := httptest.NewRecorder() + reqContact, _ := http.NewRequest("GET", "/api/v1/widget/contact", nil) + reqContact.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wContact, reqContact) + require.Equal(t, http.StatusOK, wContact.Code) +} + // ========== SendMessage Handler Tests ========== func TestWidgetHandler_SendMessage_Success(t *testing.T) { @@ -679,4 +763,4 @@ func TestWidgetHandler_Init_HMACInvalid(t *testing.T) { var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.NotEmpty(t, resp["widget_token"]) -} \ No newline at end of file +} diff --git a/internal/router/router.go b/internal/router/router.go index be0dff84..6249f689 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1702,28 +1702,28 @@ func registerPlatformTokenRoutes(g *gin.RouterGroup, h *Handlers) { // Some behavior still delegates to tracked stubs until widget serializer parity is completed. func registerChatwootWidgetRoutes(g *gin.RouterGroup, h *Handlers) { g.POST("/direct_uploads", h.Upload.DirectUpload) - g.POST("/config", h.Widget.Init) + g.POST("/config", h.Widget.Config) g.GET("/campaigns", chatwootParityStub) g.POST("/events", chatwootParityStub) - g.GET("/messages", chatwootParityStub) + 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.GET("/conversations", h.Widget.GetConversations) - g.POST("/conversations", chatwootParityStub) - g.POST("/conversations/destroy_custom_attributes", chatwootParityStub) - g.POST("/conversations/set_custom_attributes", chatwootParityStub) - g.POST("/conversations/update_last_seen", chatwootParityStub) - g.POST("/conversations/toggle_typing", chatwootParityStub) + g.POST("/conversations", h.Widget.CreateConversation) + g.POST("/conversations/destroy_custom_attributes", h.Widget.DestroyConversationCustomAttributes) + 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.GET("/conversations/toggle_status", chatwootParityStub) + g.GET("/conversations/toggle_status", h.Widget.ToggleStatus) - g.GET("/contact", chatwootParityStub) + g.GET("/contact", h.Widget.GetContact) g.PUT("/contact", h.Widget.UpdateContact) g.PATCH("/contact", h.Widget.UpdateContact) - g.POST("/contact/destroy_custom_attributes", chatwootParityStub) + g.POST("/contact/destroy_custom_attributes", h.Widget.DestroyContactCustomAttributes) g.PATCH("/contact/set_user", chatwootParityStub) g.GET("/inbox_members", chatwootParityStub) diff --git a/internal/service/widget_service.go b/internal/service/widget_service.go index a6d4ab7b..c1a6d2b5 100644 --- a/internal/service/widget_service.go +++ b/internal/service/widget_service.go @@ -9,12 +9,14 @@ import ( "errors" "fmt" "strconv" + "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/search" ws "github.com/gochat/gochat/internal/ws" applogger "github.com/gochat/gochat/pkg/logger" + "gorm.io/datatypes" ) // TypingIndicator is the interface for broadcasting typing events. @@ -33,16 +35,16 @@ type TypingIndicator interface { // Contact identity is established via widget_token (pubsub_token in Chatwoot), // which is issued on widget init/auth and carried in subsequent requests. type WidgetService struct { - inboxRepo *repository.InboxRepo - contactRepo *repository.ContactRepo - contactInboxRepo *repository.ContactInboxRepo - conversationRepo *repository.ConversationRepo - messageRepo *repository.MessageRepo - typingIndicator TypingIndicator - themeConfigRepo *repository.WidgetThemeConfigRepo - preChatFormRepo *repository.PreChatFormRepo -fileUploadRepo *repository.WidgetFileUploadRepo - offlineMessageRepo *repository.WidgetOfflineMessageRepo + inboxRepo *repository.InboxRepo + contactRepo *repository.ContactRepo + contactInboxRepo *repository.ContactInboxRepo + conversationRepo *repository.ConversationRepo + messageRepo *repository.MessageRepo + typingIndicator TypingIndicator + themeConfigRepo *repository.WidgetThemeConfigRepo + preChatFormRepo *repository.PreChatFormRepo + fileUploadRepo *repository.WidgetFileUploadRepo + offlineMessageRepo *repository.WidgetOfflineMessageRepo } // NewWidgetService creates a new Widget service. @@ -59,16 +61,16 @@ func NewWidgetService( offlineMessageRepo *repository.WidgetOfflineMessageRepo, ) *WidgetService { return &WidgetService{ - inboxRepo: inboxRepo, - contactRepo: contactRepo, - contactInboxRepo: contactInboxRepo, - conversationRepo: conversationRepo, - messageRepo: messageRepo, - typingIndicator: typingIndicator, - themeConfigRepo: themeConfigRepo, - preChatFormRepo: preChatFormRepo, - fileUploadRepo: fileUploadRepo, - offlineMessageRepo: offlineMessageRepo, + inboxRepo: inboxRepo, + contactRepo: contactRepo, + contactInboxRepo: contactInboxRepo, + conversationRepo: conversationRepo, + messageRepo: messageRepo, + typingIndicator: typingIndicator, + themeConfigRepo: themeConfigRepo, + preChatFormRepo: preChatFormRepo, + fileUploadRepo: fileUploadRepo, + offlineMessageRepo: offlineMessageRepo, } } @@ -93,8 +95,10 @@ type WidgetInitResponse struct { ContactID uint `json:"contact_id"` ContactInboxID uint `json:"contact_inbox_id"` InboxID uint `json:"inbox_id"` + InboxName string `json:"inbox_name"` AccountID uint `json:"account_id"` WidgetConfig WebWidgetConfig `json:"widget_config"` + Contact *model.Contact `json:"-"` } // WidgetSendMessageRequest is the DTO for the /widget/messages endpoint. @@ -112,6 +116,13 @@ type WidgetSendMessageResponse struct { Message model.Message `json:"message"` } +type WidgetContactUpdate struct { + Name string + Email string + PhoneNumber string + CustomAttributes map[string]any +} + // WidgetGetCableTokenResponse returns the pubsub_token for WebSocket connection. // Reference: Chatwoot widget SDK — fetches token for ActionCable subscription type WidgetGetCableTokenResponse struct { @@ -172,8 +183,10 @@ func (s *WidgetService) Init(ctx context.Context, req WidgetInitRequest) (*Widge ContactID: contact.ID, ContactInboxID: contactInbox.ID, InboxID: inbox.ID, + InboxName: inbox.Name, AccountID: inbox.AccountID, WidgetConfig: *widgetConfig, + Contact: contact, }, nil } @@ -260,6 +273,49 @@ func (s *WidgetService) GetConversations(ctx context.Context, widgetToken string return conversations, nil } +func (s *WidgetService) GetLatestConversation(ctx context.Context, widgetToken string) (*model.Conversation, error) { + contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) + if err != nil { + return nil, fmt.Errorf("invalid widget_token: %w", err) + } + conversations, _, err := s.conversationRepo.FindByContact( + ctx, contactInbox.Contact.AccountID, contactInbox.ContactID, 0, 1) + if err != nil { + return nil, err + } + if len(conversations) == 0 { + return nil, errors.New("conversation not found") + } + return &conversations[0], nil +} + +func (s *WidgetService) GetConversation(ctx context.Context, widgetToken string, conversationID uint) (*model.Conversation, error) { + contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) + if err != nil { + return nil, fmt.Errorf("invalid widget_token: %w", err) + } + conversation, err := s.conversationRepo.FindByID(ctx, conversationID) + if err != nil { + return nil, err + } + if conversation.ContactID != contactInbox.ContactID { + return nil, errors.New("conversation does not belong to this contact") + } + return conversation, nil +} + +func (s *WidgetService) GetLatestConversationMessages(ctx context.Context, widgetToken string, offset, limit int) ([]model.Message, int64, *model.Conversation, error) { + conversation, err := s.GetLatestConversation(ctx, widgetToken) + if err != nil { + if err.Error() == "conversation not found" { + return []model.Message{}, 0, nil, nil + } + return nil, 0, nil, err + } + messages, total, err := s.messageRepo.FindByConversation(ctx, conversation.ID, offset, limit) + return messages, total, conversation, err +} + // 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) @@ -305,6 +361,18 @@ func (s *WidgetService) GetCableToken(ctx context.Context, widgetToken string) ( // UpdateContact updates the contact's profile from the widget. // Reference: Chatwoot widget SDK — update contact name/email func (s *WidgetService) UpdateContact(ctx context.Context, widgetToken string, name, email string) (*model.Contact, error) { + return s.UpdateContactProfile(ctx, widgetToken, WidgetContactUpdate{Name: name, Email: email}) +} + +func (s *WidgetService) GetContact(ctx context.Context, widgetToken string) (*model.Contact, error) { + contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) + if err != nil { + return nil, fmt.Errorf("invalid widget_token: %w", err) + } + return s.contactRepo.FindByID(ctx, contactInbox.ContactID) +} + +func (s *WidgetService) UpdateContactProfile(ctx context.Context, widgetToken string, req WidgetContactUpdate) (*model.Contact, error) { contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) if err != nil { return nil, fmt.Errorf("invalid widget_token: %w", err) @@ -315,11 +383,21 @@ func (s *WidgetService) UpdateContact(ctx context.Context, widgetToken string, n return nil, err } - if name != "" { - contact.Name = name + if req.Name != "" { + contact.Name = req.Name } - if email != "" { - contact.Email = email + if req.Email != "" { + contact.Email = req.Email + } + 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 err := s.contactRepo.Update(ctx, contact); err != nil { @@ -328,6 +406,22 @@ func (s *WidgetService) UpdateContact(ctx context.Context, widgetToken string, n return contact, nil } +func (s *WidgetService) DeleteContactCustomAttributes(ctx context.Context, widgetToken string, keys []string) (*model.Contact, error) { + contact, err := s.GetContact(ctx, widgetToken) + if err != nil { + return nil, err + } + attrs := jsonMap(contact.CustomAttributes) + for _, key := range keys { + delete(attrs, key) + } + contact.CustomAttributes = mustJSON(attrs) + if err := s.contactRepo.Update(ctx, contact); err != nil { + return nil, err + } + return contact, nil +} + // ToggleTyping signals that the widget contact is typing or stopped typing. // Reference: Chatwoot ConversationTypingOnJob / ConversationTypingOffJob func (s *WidgetService) ToggleTyping(ctx context.Context, widgetToken string, conversationID uint, typing bool) error { @@ -358,6 +452,59 @@ func (s *WidgetService) ToggleTyping(ctx context.Context, widgetToken string, co return s.typingIndicator.SetTypingOff(ctx, conversation.AccountID, conversationID, performer) } +func (s *WidgetService) UpdateLastSeen(ctx context.Context, widgetToken string) (*model.Conversation, error) { + conversation, err := s.GetLatestConversation(ctx, widgetToken) + 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) ResolveLatestConversation(ctx context.Context, widgetToken string) (*model.Conversation, error) { + conversation, err := s.GetLatestConversation(ctx, widgetToken) + 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) SetLatestConversationCustomAttributes(ctx context.Context, widgetToken string, attrs map[string]any) (*model.Conversation, error) { + conversation, err := s.GetLatestConversation(ctx, widgetToken) + if err != nil { + return nil, err + } + conversation.CustomAttributes = mustJSON(attrs) + if err := s.conversationRepo.Update(ctx, conversation); err != nil { + return nil, err + } + return conversation, nil +} + +func (s *WidgetService) DeleteLatestConversationCustomAttributes(ctx context.Context, widgetToken string, keys []string) (*model.Conversation, error) { + conversation, err := s.GetLatestConversation(ctx, widgetToken) + if err != nil { + return nil, err + } + attrs := jsonMap(conversation.CustomAttributes) + for _, key := range keys { + delete(attrs, key) + } + conversation.CustomAttributes = mustJSON(attrs) + if err := s.conversationRepo.Update(ctx, conversation); err != nil { + return nil, err + } + return conversation, nil +} + // --- Helper methods --- // findInboxByWebsiteToken scans all web_widget inboxes to find one with matching @@ -673,3 +820,26 @@ func (s *WidgetService) DismissOfflineMessage(ctx context.Context, offlineMsgID func (s *WidgetService) CountPendingOfflineMessages(ctx context.Context, inboxID uint) (int64, error) { return s.offlineMessageRepo.CountPendingByInboxID(ctx, inboxID) } + +func jsonMap(raw datatypes.JSON) map[string]any { + out := map[string]any{} + if len(raw) == 0 { + return out + } + _ = json.Unmarshal(raw, &out) + if out == nil { + out = map[string]any{} + } + return out +} + +func mustJSON(value map[string]any) datatypes.JSON { + if value == nil { + value = map[string]any{} + } + b, err := json.Marshal(value) + if err != nil { + return datatypes.JSON([]byte("{}")) + } + return datatypes.JSON(b) +}