diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 522ec797..61f2f76c 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,11 +49,11 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-07 P3.96 conversation last-seen read parity, prepared as `feat(conversations): align last seen reads`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align last seen reads`. -- Latest documentation/tooling checkpoint: this tracker update records Chatwoot conversation `update_last_seen`/`unread` timestamp and notification-read parity. No route artifacts change. +- Current tracking checkpoint: 2026-06-07 P3.97 conversation typing event parity, prepared as `feat(conversations): align typing events`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align typing events`. +- Latest documentation/tooling checkpoint: this tracker update records Chatwoot conversation `toggle_typing_status` permissive request and event parity. No route artifacts change. - Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker. -- Worktree status at this implementation checkpoint: account conversation `update_last_seen` now marks current-user conversation notifications read, updates assignee and agent last-seen columns according to Chatwoot unread/throttle rules, and skips redundant writes when there are no unread messages and recent timestamps; `unread` now backdates both agent and assignee last-seen columns to the last incoming message minus one second. This retains P3.95 conversation mute contact-block parity, P3.94 conversation toggle-priority empty-body parity, P3.93 conversation toggle-status fallback parity, P3.92 automation-rule attachment parity, P3.91 upload/macro attachment parity, P3.90 SearchAPI filter-surface parity, P3.89 fixed page size, P3.88 global result type scoping, P3.87 article payloads, P3.86 message payloads, P3.85 conversation payloads, P3.84 agent-sender filter parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Worktree status at this implementation checkpoint: account conversation `toggle_typing_status` now accepts empty bodies or missing `typing_status` as Chatwoot no-op `200 OK`, still rejects malformed JSON, and dispatches `conversation.typing_on` / `conversation.typing_off` channel events with current user and `is_private` metadata when `on`/`off` status is present. This retains P3.96 conversation last-seen read parity, P3.95 conversation mute contact-block parity, P3.94 conversation toggle-priority empty-body parity, P3.93 conversation toggle-status fallback parity, P3.92 automation-rule attachment parity, P3.91 upload/macro attachment parity, P3.90 SearchAPI filter-surface parity, P3.89 fixed page size, P3.88 global result type scoping, P3.87 article payloads, P3.86 message payloads, P3.85 conversation payloads, P3.84 agent-sender filter parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. - Next executable implementation checkpoint: continue Phase 2/3 drift audit for the next reused-frontend mismatch, or run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes. - `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full. - Route dump succeeds with `967` registered routes after profile MFA route tracking. @@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | +| 0 | P3.97 conversation typing event parity | Implemented for reused conversation typing action: `POST /conversations/:conversation_id/toggle_typing_status` now accepts empty bodies and `{}` as Chatwoot no-op `200 OK`, rejects malformed JSON, and emits `conversation.typing_on` / `conversation.typing_off` events with conversation/contact/user context and `is_private` metadata for valid statuses. | Keep in Review; reopen from B12 conversation typing smoke or fresh reference evidence for ActionCable event payload fields beyond the inspected `TypingStatusManager` contract. | Focused ConversationService and ConversationHandler toggle-typing tests passed; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | 0 | P3.96 conversation last-seen read parity | Implemented for reused conversation read/unread actions: `POST /conversations/:conversation_id/update_last_seen` marks current-user unread notifications for the conversation as read, updates `agent_last_seen_at` plus `assignee_last_seen_at` when the viewer is the assignee and there are unread messages, updates only agent last-seen for non-assignees, and applies Chatwoot's one-hour no-unread write throttle; `unread` backdates both last-seen columns to the last incoming message minus one second. | Keep in Review; reopen from B12 conversation read/unread smoke or fresh reference evidence for unread-count notifier/event side effects not covered by the inspected controller/model contract. | Focused ConversationService and ConversationHandler last-seen/unread tests passed; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | 0 | P3.95 conversation mute contact-block parity | Implemented for reused conversation mute/unmute actions and automation `mute_conversation`: `POST /conversations/:conversation_id/mute` now resolves the conversation, blocks the contact, and serializes `muted` from the contact blocked state; `unmute` unblocks the contact without reopening the conversation, matching Chatwoot `ConversationMuteHelpers`. | Keep in Review; reopen from B12 conversation action smoke, automation mute smoke, or fresh reference evidence for activity-message wording/Current.user side effects beyond the inspected mute helper. | Focused ConversationService, ConversationHandler CRUD, and ActionService mute tests passed; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | 0 | P3.94 conversation toggle-priority empty-body parity | Implemented for reused conversation priority action and direct API compatibility: `POST /conversations/:conversation_id/toggle_priority` now accepts empty bodies without JSON-binding failure and returns empty `200 OK`, matching Chatwoot `head :ok` behavior while using GoChat's existing no-priority sentinel. | Keep in Review; reopen from B12 conversation action smoke or fresh reference evidence for exact nil-vs-sentinel priority serialization drift. | Focused ConversationHandler toggle-priority tests passed; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | @@ -260,6 +261,7 @@ These rows are the executable development plan from this point forward. A checkp | ID | Owner files | Reference files | Work to land | Exit gate | | --- | --- | --- | --- | --- | +| P3.97 conversation typing event parity | `internal/handler/api/v1/conversation_handler.go`, `internal/service/conversation_service.go`, `internal/channel/event.go`, conversation handler/service tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#toggle_typing_status`, `reference/chatwoot/app/services/conversations/typing_status_manager.rb`, `reference/chatwoot/app/javascript/dashboard/api/inbox/conversation.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/conversationTypingStatus.js`, reused dashboard reply-box typing callers | Account conversation `toggle_typing_status` now matches Chatwoot's permissive member action: missing body or missing `typing_status` resolves the conversation and returns empty `200 OK` without dispatching an event, malformed JSON still fails, and `on`/`off` statuses dispatch `conversation.typing_on` / `conversation.typing_off` events with conversation/contact/user context plus `is_private`. | Review by `feat(conversations): align typing events`; focused tests prove service event type/data behavior, blank-status no-op after conversation lookup, handler empty-body and `{}` no-op success, and invalid JSON failure; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | P3.96 conversation last-seen read parity | `internal/service/conversation_service.go`, `internal/handler/api/v1/conversation_handler.go`, conversation handler/service tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#update_last_seen`, `#unread`, `reference/chatwoot/app/models/conversation.rb#unread_messages`, `#assignee_unread_messages`, `reference/chatwoot/app/services/notification/mark_conversation_read_service.rb`, reused dashboard `ConversationApi.markMessageRead/markMessagesUnread` and store `messageReadActions.js` | Account conversation `update_last_seen` now mirrors Chatwoot's read path: it marks unread notifications for the conversation/current user as read, updates assignee last-seen only when the current viewer is the assignee, always updates agent last-seen when unread messages exist, and throttles no-unread writes to one hour. The `unread` endpoint now uses the Chatwoot helper shape by updating both `agent_last_seen_at` and `assignee_last_seen_at` to the last incoming message timestamp minus one second, or clearing both when no incoming message exists. | Review by `feat(conversations): align last seen reads`; focused tests prove notification read side effects, assignee/non-assignee column behavior, throttle behavior, and unread dual-column backdating; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | P3.95 conversation mute contact-block parity | `internal/service/conversation_service.go`, `internal/handler/api/v1/conversation_handler.go`, `internal/handler/api/v1/conversation_serializer.go`, `internal/automation/action_service.go`, conversation handler/service/action tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#mute`, `#unmute`, `reference/chatwoot/app/models/concerns/conversation_mute_helpers.rb`, `reference/chatwoot/app/services/action_service.rb#mute_conversation`, `reference/chatwoot/app/views/api/v1/conversations/partials/_conversation.json.jbuilder`, reused conversation mute/unmute store/actions | Account conversation mute/unmute and automation `mute_conversation` now use Chatwoot's contact-block semantics instead of the old local label/legacy mute-row behavior: mute resolves the conversation, blocks the contact, sets the local conversation muted cache, and serialization derives `muted` from `contact.blocked`; unmute unblocks and clears muted without reopening. | Review by `feat(conversations): align mute semantics`; focused tests prove service persistence, handler empty `200 OK` plus serialized payload state, and automation action side effects; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | P3.94 conversation toggle-priority empty-body parity | `internal/handler/api/v1/conversation_handler.go`, `internal/service/conversation_service.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#toggle_priority`, `reference/chatwoot/app/models/conversation.rb#toggle_priority`, `reference/chatwoot/app/javascript/dashboard/api/inbox/conversation.js`, reused conversation priority action store/components | Account conversation `toggle_priority` now accepts omitted priority/empty request bodies like Chatwoot's `params[:priority]` member action and returns empty `200 OK` instead of a local validation envelope. Explicit priority updates remain handled by the existing priority service. | Review by `feat(conversations): align toggle priority fallback`; focused tests prove empty-body `toggle_priority` succeeds and invalid JSON still fails; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | @@ -380,6 +382,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(conversations): align typing events` | Advances P3.97 with Chatwoot conversation `toggle_typing_status` parity. GoChat now treats empty or missing `typing_status` as a no-op `200 OK`, keeps malformed JSON as a validation error, and dispatches Chatwoot-style `conversation.typing_on` / `conversation.typing_off` events with user/contact/conversation context and `is_private` metadata. | Focused ConversationService and ConversationHandler toggle-typing tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.97 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align last seen reads` | Advances P3.96 with Chatwoot conversation `update_last_seen` and `unread` parity. GoChat now marks current-user conversation notifications read, applies assignee-aware last-seen updates and the no-unread one-hour throttle, and marks conversations unread by backdating both agent and assignee last-seen columns. | Focused ConversationService and ConversationHandler last-seen/unread tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.96 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align mute semantics` | Advances P3.95 with Chatwoot conversation mute/unmute and automation mute parity. GoChat now follows `ConversationMuteHelpers`: mute resolves the conversation, blocks the conversation contact, keeps serialized `muted` tied to contact blocked state, and automation `mute_conversation` applies the same side effects instead of writing only a legacy mute row. | Focused ConversationService mute/unmute tests, ConversationHandler CRUD mute/unmute tests, and ActionService `mute_conversation` tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.95 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align toggle priority fallback` | Advances P3.94 with Chatwoot conversation `toggle_priority` empty-body parity. The account conversation member action now accepts omitted priority/empty bodies, keeps empty `200 OK`, and avoids the local JSON-binding failure that drifted from Chatwoot `params[:priority]`. | Focused ConversationHandler toggle-priority tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.94 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | @@ -2766,3 +2769,4 @@ Verification milestone gates: - 2026-06-07: P3.94 conversation toggle-priority checkpoint prepared as `feat(conversations): align toggle priority fallback`; audited Chatwoot `ConversationsController#toggle_priority`, `Conversation#toggle_priority`, and reused dashboard conversation priority API/store actions. GoChat account conversation `toggle_priority` now accepts omitted `priority` and empty request bodies, returns empty `200 OK` instead of a local JSON-binding error, and continues to persist the existing no-priority sentinel through the priority service. Focused ConversationHandler toggle-priority tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.95 conversation mute checkpoint prepared as `feat(conversations): align mute semantics`; audited Chatwoot `ConversationsController#mute/#unmute`, `ConversationMuteHelpers`, `ActionService#mute_conversation`, `_conversation.json.jbuilder`, and reused dashboard mute/unmute store actions. GoChat account conversation mute now resolves the conversation, blocks the contact, and serializes `muted` from contact blocked state; unmute unblocks without reopening; automation `mute_conversation` applies the same Chatwoot side effects instead of writing only a legacy mute row. Focused ConversationService, ConversationHandler CRUD, and ActionService mute tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.96 conversation last-seen checkpoint prepared as `feat(conversations): align last seen reads`; audited Chatwoot `ConversationsController#update_last_seen/#unread`, `Conversation#unread_messages/#assignee_unread_messages`, `Notification::MarkConversationReadService`, and reused dashboard message read/unread API/store actions. GoChat account conversation `update_last_seen` now marks current-user unread notifications for the conversation as read, updates agent and assignee last-seen columns according to assignee/unread-message state, and throttles no-unread writes to once per hour; `unread` now backdates or clears both agent and assignee last-seen columns. Focused ConversationService and ConversationHandler tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. +- 2026-06-07: P3.97 conversation typing checkpoint prepared as `feat(conversations): align typing events`; audited Chatwoot `ConversationsController#toggle_typing_status`, `Conversations::TypingStatusManager`, and reused dashboard conversation typing API/store/reply-box callers. GoChat account conversation `toggle_typing_status` now accepts empty bodies and missing `typing_status` as no-op `200 OK`, still rejects malformed JSON, and dispatches Chatwoot-style `conversation.typing_on` / `conversation.typing_off` events with current user, contact, conversation, and `is_private` metadata for valid `on`/`off` statuses. Focused ConversationService and ConversationHandler toggle-typing tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. diff --git a/internal/channel/event.go b/internal/channel/event.go index 4726b48b..104cf203 100644 --- a/internal/channel/event.go +++ b/internal/channel/event.go @@ -7,26 +7,28 @@ type EventType string const ( // Message events - EventMessageIncoming EventType = "message.incoming" - EventMessageOutgoing EventType = "message.outgoing" - EventMessageCreated EventType = "message.created" - EventMessageUpdated EventType = "message.updated" - EventMessageDeleted EventType = "message.deleted" + EventMessageIncoming EventType = "message.incoming" + EventMessageOutgoing EventType = "message.outgoing" + EventMessageCreated EventType = "message.created" + EventMessageUpdated EventType = "message.updated" + EventMessageDeleted EventType = "message.deleted" EventMessageStatusUpdated EventType = "message.status_updated" // Conversation events - EventConversationCreated EventType = "conversation.created" - EventConversationUpdated EventType = "conversation.updated" - EventConversationResolved EventType = "conversation.resolved" - EventConversationOpened EventType = "conversation.opened" - EventConversationAssigned EventType = "conversation.assigned" - EventConversationUnassigned EventType = "conversation.unassigned" - EventConversationDeleted EventType = "conversation.deleted" - EventConversationMuted EventType = "conversation.muted" - EventConversationUnmuted EventType = "conversation.unmuted" + EventConversationCreated EventType = "conversation.created" + EventConversationUpdated EventType = "conversation.updated" + EventConversationResolved EventType = "conversation.resolved" + EventConversationOpened EventType = "conversation.opened" + EventConversationAssigned EventType = "conversation.assigned" + EventConversationUnassigned EventType = "conversation.unassigned" + EventConversationDeleted EventType = "conversation.deleted" + EventConversationMuted EventType = "conversation.muted" + EventConversationUnmuted EventType = "conversation.unmuted" EventConversationPriorityUpdated EventType = "conversation.priority_updated" EventConversationLabelsUpdated EventType = "conversation.labels_updated" EventConversationTyping EventType = "conversation.typing" + EventConversationTypingOn EventType = "conversation.typing_on" + EventConversationTypingOff EventType = "conversation.typing_off" // Contact events EventContactCreated EventType = "contact.created" @@ -46,10 +48,10 @@ const ( EventWebhookReceived EventType = "webhook.received" // Agent events - EventAgentAdded EventType = "agent.added" - EventAgentRemoved EventType = "agent.removed" - EventAgentOnline EventType = "agent.online" - EventAgentOffline EventType = "agent.offline" + EventAgentAdded EventType = "agent.added" + EventAgentRemoved EventType = "agent.removed" + EventAgentOnline EventType = "agent.online" + EventAgentOffline EventType = "agent.offline" // Typing events EventTypingStart EventType = "typing.start" @@ -59,25 +61,25 @@ const ( // ChannelEvent wraps an event with full context for event bus propagation. // Reference: P2D §8 — events carry enough context for handlers to process without DB lookups type ChannelEvent struct { - Type EventType `json:"type"` - Channel ChannelType `json:"channel"` - AccountID uint `json:"account_id"` - InboxID uint `json:"inbox_id"` - ConversationID uint `json:"conversation_id,omitempty"` - ContactID uint `json:"contact_id,omitempty"` - UserID uint `json:"user_id,omitempty"` - Data map[string]interface{} `json:"data"` - Timestamp int64 `json:"timestamp"` + Type EventType `json:"type"` + Channel ChannelType `json:"channel"` + AccountID uint `json:"account_id"` + InboxID uint `json:"inbox_id"` + ConversationID uint `json:"conversation_id,omitempty"` + ContactID uint `json:"contact_id,omitempty"` + UserID uint `json:"user_id,omitempty"` + Data map[string]interface{} `json:"data"` + Timestamp int64 `json:"timestamp"` } // NewChannelEvent creates a new channel event with timestamp. func NewChannelEvent(eventType EventType, channelType ChannelType, accountID, inboxID uint) *ChannelEvent { return &ChannelEvent{ - Type: eventType, - Channel: channelType, - AccountID: accountID, - InboxID: inboxID, - Data: make(map[string]interface{}), - Timestamp: 0, // will be set by event bus publisher + Type: eventType, + Channel: channelType, + AccountID: accountID, + InboxID: inboxID, + Data: make(map[string]interface{}), + Timestamp: 0, // will be set by event bus publisher } -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/conversation_handler.go b/internal/handler/api/v1/conversation_handler.go index d0a76464..d93bba52 100644 --- a/internal/handler/api/v1/conversation_handler.go +++ b/internal/handler/api/v1/conversation_handler.go @@ -890,18 +890,21 @@ func (h *ConversationHandler) ToggleTyping(c *gin.Context) { } var req struct { - TypingStatus string `json:"typing_status" binding:"required"` + TypingStatus string `json:"typing_status"` + IsPrivate bool `json:"is_private"` } - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) - return + if c.Request.Body != nil && c.Request.ContentLength != 0 { + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) + return + } } conversation, ok := h.resolveConversationRoute(c, accountID, conversationID) if !ok { return } - svcErr := h.conversationSvc.ToggleTyping(c.Request.Context(), accountID, conversation.ID, req.TypingStatus) + svcErr := h.conversationSvc.ToggleTyping(c.Request.Context(), accountID, conversation.ID, currentUserID(c), req.TypingStatus, req.IsPrivate) if svcErr != nil { handleServiceError(c, svcErr) return diff --git a/internal/handler/api/v1/conversation_handler_test.go b/internal/handler/api/v1/conversation_handler_test.go index fa4d63f4..a1892202 100644 --- a/internal/handler/api/v1/conversation_handler_test.go +++ b/internal/handler/api/v1/conversation_handler_test.go @@ -728,11 +728,28 @@ func (s *ConversationHandlerTestSuite) TestToggleTyping_InvalidConversationID() assert.Equal(s.T(), http.StatusBadRequest, w.Code) } -func (s *ConversationHandlerTestSuite) TestToggleTyping_MissingFields() { +func (s *ConversationHandlerTestSuite) TestToggleTyping_MissingFieldsIsNoopSuccess() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/toggle_typing", s.testAccount.ID, s.testConv.ID), bytes.NewBufferString(`{}`)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Empty(s.T(), w.Body.String()) +} + +func (s *ConversationHandlerTestSuite) TestToggleTyping_EmptyBodyIsNoopSuccess() { + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/toggle_typing", s.testAccount.ID, s.testConv.ID), nil) + s.router.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Empty(s.T(), w.Body.String()) +} + +func (s *ConversationHandlerTestSuite) TestToggleTyping_InvalidJSON() { + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/toggle_typing", s.testAccount.ID, s.testConv.ID), bytes.NewBufferString(`{`)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } diff --git a/internal/service/conversation_service.go b/internal/service/conversation_service.go index 5fe62514..9cec172d 100644 --- a/internal/service/conversation_service.go +++ b/internal/service/conversation_service.go @@ -1663,18 +1663,36 @@ func (s *ConversationService) visibleUnreadCountTeamIDs(ctx context.Context, acc // ToggleTyping broadcasts a typing status event for an agent in a conversation. // Reference: Chatwoot app/controllers/api/v1/accounts/conversations_controller.rb #toggle_typing_status -func (s *ConversationService) ToggleTyping(ctx context.Context, accountID, conversationID uint, typingStatus string) error { +func (s *ConversationService) ToggleTyping(ctx context.Context, accountID, conversationID, userID uint, typingStatus string, isPrivate bool) error { conversation, err := s.GetByAccountAndID(ctx, accountID, conversationID) if err != nil { return err } - event := channel.NewChannelEvent(channel.EventConversationTyping, channel.ChannelAPI, accountID, conversation.InboxID) + eventType, ok := conversationTypingEventType(typingStatus) + if !ok { + return nil + } + event := channel.NewChannelEvent(eventType, channel.ChannelAPI, accountID, conversation.InboxID) event.ConversationID = conversationID + event.ContactID = conversation.ContactID + event.UserID = userID event.Data["typing_status"] = typingStatus + event.Data["is_private"] = isPrivate s.dispatcher.Dispatch(ctx, event) return nil } +func conversationTypingEventType(status string) (channel.EventType, bool) { + switch strings.TrimSpace(status) { + case "on", "typing_on": + return channel.EventConversationTypingOn, true + case "off", "typing_off": + return channel.EventConversationTypingOff, true + default: + return "", false + } +} + // UpdateLastSeen marks the current user as having viewed a conversation. // Reference: Chatwoot conversations_controller.rb #update_last_seen func (s *ConversationService) UpdateLastSeen(ctx context.Context, accountID, conversationID, userID uint) error { diff --git a/internal/service/conversation_service_test.go b/internal/service/conversation_service_test.go index 2f922c89..32c1cee5 100644 --- a/internal/service/conversation_service_test.go +++ b/internal/service/conversation_service_test.go @@ -270,6 +270,46 @@ func TestConversationService_Create_DispatchesInitialMessageWithConversationData assert.Equal(t, inbox.ID, message.InboxID) } +func TestConversationService_ToggleTyping_DispatchesChatwootTypingEvents(t *testing.T) { + svc, db := setupConversationService(t) + capture := &captureConversationEventsListener{} + svc.dispatcher.Register(capture) + + account := createConversationServiceTestAccount(t, db) + inbox := createConversationServiceTestInbox(t, db, account.ID) + contact := createConversationServiceTestContact(t, db, account.ID) + conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + userID := uint(42) + + require.NoError(t, svc.ToggleTyping(context.Background(), account.ID, conversation.ID, userID, "on", true)) + require.NoError(t, svc.ToggleTyping(context.Background(), account.ID, conversation.ID, userID, "off", false)) + + require.Len(t, capture.events, 2) + assert.Equal(t, channel.EventConversationTypingOn, capture.events[0].Type) + assert.Equal(t, conversation.ID, capture.events[0].ConversationID) + assert.Equal(t, contact.ID, capture.events[0].ContactID) + assert.Equal(t, userID, capture.events[0].UserID) + assert.Equal(t, true, capture.events[0].Data["is_private"]) + assert.Equal(t, channel.EventConversationTypingOff, capture.events[1].Type) +} + +func TestConversationService_ToggleTyping_BlankStatusIsNoopAfterConversationLookup(t *testing.T) { + svc, db := setupConversationService(t) + capture := &captureConversationEventsListener{} + svc.dispatcher.Register(capture) + + account := createConversationServiceTestAccount(t, db) + inbox := createConversationServiceTestInbox(t, db, account.ID) + contact := createConversationServiceTestContact(t, db, account.ID) + conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + + require.NoError(t, svc.ToggleTyping(context.Background(), account.ID, conversation.ID, 0, "", false)) + assert.Empty(t, capture.events) + + err := svc.ToggleTyping(context.Background(), account.ID, 99999, 0, "", false) + assert.Error(t, err) +} + // ========== GetMeta Tests ========== func TestConversationService_GetMeta(t *testing.T) {