feat(widget): implement chatwoot widget core handlers
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 != "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user