Align GoChat with Chatwoot frontend contracts

This commit is contained in:
2026-06-13 22:13:32 +08:00
parent 71abf58636
commit d884fdda0a
162 changed files with 9825 additions and 528 deletions
+57 -11
View File
@@ -18,19 +18,20 @@ import (
// after create/update/delete actions via Wisper → ActionCable.
//
// Architecture:
// Service.CreateX() → EventPublisher.PublishEvent(accountID, eventType, payload)
// → Hub.SendToAccount (WebSocket local delivery)
// → SSERegistry.SendToAccount (SSE local delivery)
// → BroadcastRelay.Publish (Redis Pub/Sub cross-instance delivery)
//
// Service.CreateX() → EventPublisher.PublishEvent(accountID, eventType, payload)
// → Hub.SendToAccount (WebSocket local delivery)
// → SSERegistry.SendToAccount (SSE local delivery)
// → BroadcastRelay.Publish (Redis Pub/Sub cross-instance delivery)
//
// Reference: Chatwoot uses Wisper (in-process pub/sub) + ActionCable (WebSocket)
// + Redis Pub/Sub for cross-instance. GoChat uses EventPublisher as the unified
// entry point, with Hub and SSERegistry as local delivery targets, and Redis
// Pub/Sub relay for multi-instance fan-out.
type EventPublisher struct {
hub MessageHandler // WebSocket Hub (local delivery)
sse *SSERegistry // SSE registry (local delivery)
relay *BroadcastRelay // Redis Pub/Sub relay (cross-instance delivery)
hub MessageHandler // WebSocket Hub (local delivery)
sse *SSERegistry // SSE registry (local delivery)
relay *BroadcastRelay // Redis Pub/Sub relay (cross-instance delivery)
}
// NewEventPublisher creates an EventPublisher with all delivery targets.
@@ -60,9 +61,9 @@ func NewEventPublisherLocal(hub MessageHandler, sse *SSERegistry) *EventPublishe
// - payload: event data (will be JSON-encoded for transport)
//
// The event is routed to:
// 1. WebSocket Hub → all locally connected WS clients for that account
// 2. SSE Registry → all locally connected SSE clients for that account
// 3. Redis Pub/Sub relay → all other GoChat instances (cross-instance delivery)
// 1. WebSocket Hub → all locally connected WS clients for that account
// 2. SSE Registry → all locally connected SSE clients for that account
// 3. Redis Pub/Sub relay → all other GoChat instances (cross-instance delivery)
//
// Reference: Chatwoot controllers call broadcast_event after mutations,
// which triggers Wisper → ActionCable → Redis Pub/Sub relay.
@@ -145,6 +146,47 @@ func (p *EventPublisher) PublishConversationEvent(accountID uint, conversationID
}
}
// PublishWidgetEvent publishes an event to the account room and the widget
// contact's pubsub_token room. Chatwoot's widget ActionCable connector
// subscribes to RoomChannel with pubsub_token, so widget-visible events must be
// available on that token-scoped room in addition to the dashboard account room.
func (p *EventPublisher) PublishWidgetEvent(accountID uint, pubsubToken string, eventType string, payload interface{}) {
wsMsg := &WSMessage{
Event: eventType,
Data: payload,
AccountID: accountID,
}
data, err := json.Marshal(wsMsg)
if err != nil {
applogger.L().Warnf("event publisher: failed to marshal widget event %s: %v", eventType, err)
return
}
if p.hub != nil {
p.hub.SendToAccount(accountID, data)
if pubsubToken != "" {
p.hub.SendToRoom(pubsubTokenRoomNameHelper(pubsubToken), data)
}
}
if p.sse != nil {
p.sse.SendToAccount(accountID, SSEEvent{Type: eventType, Payload: payload})
}
if p.relay != nil {
room := accountRoomNameHelper(accountID)
if err := p.relay.Publish(context.Background(), room, wsMsg); err != nil {
applogger.L().Warnf("event publisher: redis publish failed for %s: %v", eventType, err)
}
if pubsubToken != "" {
if err := p.relay.Publish(context.Background(), pubsubTokenRoomNameHelper(pubsubToken), wsMsg); err != nil {
applogger.L().Warnf("event publisher: redis publish failed for widget %s: %v", eventType, err)
}
}
}
}
// accountRoomNameHelper generates the room name for an account channel.
func accountRoomNameHelper(accountID uint) string {
return fmt.Sprintf("account_%d", accountID)
@@ -153,4 +195,8 @@ func accountRoomNameHelper(accountID uint) string {
// conversationRoomNameHelper generates the room name for a conversation channel.
func conversationRoomNameHelper(accountID uint, conversationID uint) string {
return fmt.Sprintf("account_%d_conversation_%d", accountID, conversationID)
}
}
func pubsubTokenRoomNameHelper(token string) string {
return fmt.Sprintf("pubsub_token_%s", token)
}
+428 -3
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -76,7 +77,7 @@ func TestEventPublisher_ConversationEvent(t *testing.T) {
publisher := NewEventPublisherLocal(hub, sse)
// Subscribe SSE clients
chBroad := sse.Subscribe("sse_broad", 1, 100) // no conv filter
chBroad := sse.Subscribe("sse_broad", 1, 100) // no conv filter
chFiltered := sse.Subscribe("sse_filtered", 1, 200)
sse.SubscribeConversation("sse_filtered", 42)
@@ -135,6 +136,8 @@ func TestEventPublisher_AllEventTypes(t *testing.T) {
EventInboxDeleted,
EventNotificationCreated,
EventNotificationUpdated,
EventNotificationDeleted,
EventAccountCacheInvalidated,
}
for _, eventType := range eventTypes {
@@ -221,6 +224,406 @@ func TestEventPublisher_WSMessageFormat(t *testing.T) {
assert.Equal(t, "test message", dataMap["content"])
}
func TestEventPublisher_MessageCreatedChatwootPayloadShape(t *testing.T) {
hub := newMockHandler()
sse := NewSSERegistry()
publisher := NewEventPublisherLocal(hub, sse)
ch := sse.Subscribe("message-created-fixture", 1, 100)
require.NotNil(t, ch)
payload := map[string]interface{}{
"id": 501,
"conversation_id": 42,
"display_id": 42,
"account_id": 1,
"inbox_id": 7,
"message_type": "outgoing",
"content_type": "text",
"content": "hello from agent",
"content_attributes": map[string]interface{}{"submitted_email": "contact@example.com"},
"sender": map[string]interface{}{
"id": 100,
"name": "Agent Ada",
"type": "user",
"email": "ada@example.com",
},
"attachments": []interface{}{
map[string]interface{}{
"id": 900,
"message_id": 501,
"file_type": "image",
"data_url": "https://cdn.example.test/image.png",
"download_url": "https://cdn.example.test/image.png",
"conversation_id": 42,
},
},
}
publisher.PublishEvent(1, EventMessageCreated, payload)
data := hub.getAccountData(1)
require.NotNil(t, data)
var wsMsg WSMessage
require.NoError(t, json.Unmarshal(data, &wsMsg))
require.Equal(t, EventMessageCreated, wsMsg.Event)
require.Equal(t, uint(1), wsMsg.AccountID)
dataMap, ok := wsMsg.Data.(map[string]interface{})
require.True(t, ok)
require.Equal(t, float64(501), dataMap["id"])
require.Equal(t, float64(42), dataMap["conversation_id"])
require.Equal(t, "outgoing", dataMap["message_type"])
require.Equal(t, "text", dataMap["content_type"])
require.Equal(t, "hello from agent", dataMap["content"])
require.Contains(t, dataMap, "content_attributes")
require.Contains(t, dataMap, "sender")
require.Contains(t, dataMap, "attachments")
require.Equal(t, "Agent Ada", dataMap["sender"].(map[string]interface{})["name"])
attachments := dataMap["attachments"].([]interface{})
require.Len(t, attachments, 1)
require.Equal(t, float64(42), attachments[0].(map[string]interface{})["conversation_id"])
select {
case event := <-ch.Events:
require.Equal(t, EventMessageCreated, event.Type)
fixture := event.Payload.(map[string]interface{})
require.Equal(t, "hello from agent", fixture["content"])
require.Contains(t, fixture, "sender")
require.Contains(t, fixture, "attachments")
default:
t.Fatal("SSE channel should receive Chatwoot message.created payload")
}
}
func TestEventPublisher_MessageUpdatedChatwootPayloadShape(t *testing.T) {
hub := newMockHandler()
sse := NewSSERegistry()
publisher := NewEventPublisherLocal(hub, sse)
ch := sse.Subscribe("message-updated-fixture", 1, 100)
require.NotNil(t, ch)
payload := map[string]interface{}{
"id": 502,
"conversation_id": 42,
"account_id": 1,
"inbox_id": 7,
"message_type": "outgoing",
"content_type": "text",
"content": "provider rejected",
"status": "failed",
"content_attributes": map[string]interface{}{
"external_error": "provider rejected message",
},
"sender": map[string]interface{}{
"id": 100,
"name": "Agent Ada",
"type": "user",
"email": "ada@example.com",
},
"attachments": []interface{}{},
}
publisher.PublishEvent(1, EventMessageUpdated, payload)
data := hub.getAccountData(1)
require.NotNil(t, data)
var wsMsg WSMessage
require.NoError(t, json.Unmarshal(data, &wsMsg))
require.Equal(t, EventMessageUpdated, wsMsg.Event)
require.Equal(t, uint(1), wsMsg.AccountID)
dataMap, ok := wsMsg.Data.(map[string]interface{})
require.True(t, ok)
require.Equal(t, float64(502), dataMap["id"])
require.Equal(t, float64(42), dataMap["conversation_id"])
require.Equal(t, "failed", dataMap["status"])
require.Equal(t, "provider rejected", dataMap["content"])
contentAttrs := dataMap["content_attributes"].(map[string]interface{})
require.Equal(t, "provider rejected message", contentAttrs["external_error"])
require.Contains(t, dataMap, "sender")
require.Contains(t, dataMap, "attachments")
select {
case event := <-ch.Events:
require.Equal(t, EventMessageUpdated, event.Type)
fixture := event.Payload.(map[string]interface{})
require.Equal(t, 502, fixture["id"])
require.Equal(t, "failed", fixture["status"])
case <-time.After(time.Second):
t.Fatal("SSE channel should receive message.updated fixture")
}
}
func TestEventPublisher_ConversationMutationChatwootPayloadShape(t *testing.T) {
hub := newMockHandler()
sse := NewSSERegistry()
publisher := NewEventPublisherLocal(hub, sse)
ch := sse.Subscribe("conversation-mutation-fixture", 1, 100)
require.NotNil(t, ch)
payload := map[string]interface{}{
"id": 42,
"account_id": 1,
"inbox_id": 7,
"status": "resolved",
"priority": "urgent",
"labels": []interface{}{"support", "urgent"},
"assignee_id": 100,
"team_id": 55,
"changed_attributes": map[string]interface{}{
"status": map[string]interface{}{"from": "open", "to": "resolved"},
"priority": map[string]interface{}{"from": "medium", "to": "urgent"},
},
"meta": map[string]interface{}{
"assignee": map[string]interface{}{"id": 100, "name": "Agent Ada"},
"team": map[string]interface{}{"id": 55, "name": "Support"},
},
}
publisher.PublishEvent(1, EventConversationUpdated, payload)
data := hub.getAccountData(1)
require.NotNil(t, data)
var wsMsg WSMessage
require.NoError(t, json.Unmarshal(data, &wsMsg))
require.Equal(t, EventConversationUpdated, wsMsg.Event)
require.Equal(t, uint(1), wsMsg.AccountID)
dataMap, ok := wsMsg.Data.(map[string]interface{})
require.True(t, ok)
require.Equal(t, float64(42), dataMap["id"])
require.Equal(t, "resolved", dataMap["status"])
require.Equal(t, "urgent", dataMap["priority"])
require.Contains(t, dataMap, "labels")
require.Contains(t, dataMap, "meta")
changes := dataMap["changed_attributes"].(map[string]interface{})
require.Equal(t, "resolved", changes["status"].(map[string]interface{})["to"])
require.Equal(t, "urgent", changes["priority"].(map[string]interface{})["to"])
select {
case event := <-ch.Events:
require.Equal(t, EventConversationUpdated, event.Type)
fixture := event.Payload.(map[string]interface{})
require.Equal(t, 42, fixture["id"])
require.Equal(t, "resolved", fixture["status"])
case <-time.After(time.Second):
t.Fatal("SSE channel should receive conversation.updated fixture")
}
}
func TestEventPublisher_TypingChatwootPayloadShape(t *testing.T) {
hub := newMockHandler()
sse := NewSSERegistry()
publisher := NewEventPublisherLocal(hub, sse)
ch := sse.Subscribe("typing-fixture", 1, 100)
sse.SubscribeConversation("typing-fixture", 42)
require.NotNil(t, ch)
payload := map[string]interface{}{
"conversation_id": 42,
"account_id": 1,
"user_id": 100,
"typing_status": "on",
"is_private": true,
}
publisher.PublishConversationEvent(1, 42, EventConversationTypingOn, payload)
data := hub.getAccountData(1)
require.NotNil(t, data)
var wsMsg WSMessage
require.NoError(t, json.Unmarshal(data, &wsMsg))
require.Equal(t, EventConversationTypingOn, wsMsg.Event)
dataMap := wsMsg.Data.(map[string]interface{})
require.Equal(t, float64(42), dataMap["conversation_id"])
require.Equal(t, float64(100), dataMap["user_id"])
require.Equal(t, "on", dataMap["typing_status"])
require.Equal(t, true, dataMap["is_private"])
select {
case event := <-ch.Events:
require.Equal(t, EventConversationTypingOn, event.Type)
fixture := event.Payload.(map[string]interface{})
require.Equal(t, 42, fixture["conversation_id"])
require.Equal(t, "on", fixture["typing_status"])
case <-time.After(time.Second):
t.Fatal("SSE channel should receive conversation.typing_on fixture")
}
}
func TestEventPublisher_PresenceUpdateChatwootPayloadShape(t *testing.T) {
hub := newMockHandler()
sse := NewSSERegistry()
publisher := NewEventPublisherLocal(hub, sse)
ch := sse.Subscribe("presence-fixture", 1, 100)
require.NotNil(t, ch)
payload := map[string]interface{}{
"account_id": 1,
"users": map[uint]string{100: "busy"},
"contacts": map[uint]string{501: "online"},
}
publisher.PublishEvent(1, EventPresenceUpdate, payload)
data := hub.getAccountData(1)
require.NotNil(t, data)
var wsMsg WSMessage
require.NoError(t, json.Unmarshal(data, &wsMsg))
require.Equal(t, EventPresenceUpdate, wsMsg.Event)
require.Equal(t, uint(1), wsMsg.AccountID)
dataMap := wsMsg.Data.(map[string]interface{})
require.Equal(t, float64(1), dataMap["account_id"])
users := dataMap["users"].(map[string]interface{})
require.Equal(t, "busy", users["100"])
contacts := dataMap["contacts"].(map[string]interface{})
require.Equal(t, "online", contacts["501"])
select {
case event := <-ch.Events:
require.Equal(t, EventPresenceUpdate, event.Type)
fixture := event.Payload.(map[string]interface{})
require.Equal(t, map[uint]string{100: "busy"}, fixture["users"])
case <-time.After(time.Second):
t.Fatal("SSE channel should receive presence.update fixture")
}
}
func TestEventPublisher_NotificationChatwootPayloadShape(t *testing.T) {
hub := newMockHandler()
sse := NewSSERegistry()
publisher := NewEventPublisherLocal(hub, sse)
ch := sse.Subscribe("notification-fixture", 1, 100)
require.NotNil(t, ch)
notification := map[string]interface{}{
"id": 701,
"account_id": 1,
"user_id": 100,
"notification_type": "conversation_created",
"primary_actor_type": "Conversation",
"primary_actor_id": 42,
"secondary_actor_type": "Message",
"secondary_actor_id": 501,
"read_at": nil,
"snoozed_until": nil,
"created_at": "2026-06-12T10:00:00Z",
"last_activity_at": "2026-06-12T10:01:00Z",
"meta": map[string]interface{}{
"sender": map[string]interface{}{"id": 301, "name": "Visitor"},
},
"additional_attributes": map[string]interface{}{
"browser_title": "New customer message",
},
"primary_actor": map[string]interface{}{
"id": 42,
"display_id": 42,
"status": "open",
},
"secondary_actor": map[string]interface{}{
"id": 501,
"conversation_id": 42,
"content": "hello",
},
"user": map[string]interface{}{
"id": 100,
"name": "Agent Ada",
},
}
for _, eventType := range []string{EventNotificationCreated, EventNotificationUpdated, EventNotificationDeleted} {
t.Run(eventType, func(t *testing.T) {
payload := map[string]interface{}{
"notification": notification,
"unread_count": 3,
"count": 7,
}
publisher.PublishEvent(1, eventType, payload)
data := hub.getAccountData(1)
require.NotNil(t, data)
var wsMsg WSMessage
require.NoError(t, json.Unmarshal(data, &wsMsg))
require.Equal(t, eventType, wsMsg.Event)
require.Equal(t, uint(1), wsMsg.AccountID)
dataMap, ok := wsMsg.Data.(map[string]interface{})
require.True(t, ok)
require.Equal(t, float64(3), dataMap["unread_count"])
require.Equal(t, float64(7), dataMap["count"])
notificationMap := dataMap["notification"].(map[string]interface{})
require.Equal(t, float64(701), notificationMap["id"])
require.Equal(t, float64(1), notificationMap["account_id"])
require.Equal(t, "conversation_created", notificationMap["notification_type"])
require.Equal(t, "Conversation", notificationMap["primary_actor_type"])
require.Equal(t, float64(42), notificationMap["primary_actor_id"])
require.Equal(t, "Message", notificationMap["secondary_actor_type"])
require.Equal(t, float64(501), notificationMap["secondary_actor_id"])
require.Contains(t, notificationMap, "read_at")
require.Contains(t, notificationMap, "snoozed_until")
require.Contains(t, notificationMap, "meta")
require.Contains(t, notificationMap, "additional_attributes")
require.Contains(t, notificationMap, "primary_actor")
require.Contains(t, notificationMap, "secondary_actor")
require.Contains(t, notificationMap, "user")
select {
case event := <-ch.Events:
require.Equal(t, eventType, event.Type)
fixture := event.Payload.(map[string]interface{})
require.Equal(t, 3, fixture["unread_count"])
require.Equal(t, 7, fixture["count"])
require.Equal(t, notification, fixture["notification"])
case <-time.After(time.Second):
t.Fatalf("SSE channel should receive %s fixture", eventType)
}
})
}
}
func TestEventPublisher_AccountCacheInvalidatedChatwootPayloadShape(t *testing.T) {
hub := newMockHandler()
sse := NewSSERegistry()
publisher := NewEventPublisherLocal(hub, sse)
ch := sse.Subscribe("account-cache-fixture", 1, 100)
require.NotNil(t, ch)
payload := map[string]interface{}{
"cache_keys": map[string]interface{}{
"label": "labels-v2",
"inbox": "inboxes-v2",
"team": "teams-v2",
},
}
publisher.PublishEvent(1, EventAccountCacheInvalidated, payload)
data := hub.getAccountData(1)
require.NotNil(t, data)
var wsMsg WSMessage
require.NoError(t, json.Unmarshal(data, &wsMsg))
require.Equal(t, EventAccountCacheInvalidated, wsMsg.Event)
require.Equal(t, uint(1), wsMsg.AccountID)
dataMap := wsMsg.Data.(map[string]interface{})
cacheKeys := dataMap["cache_keys"].(map[string]interface{})
require.Equal(t, "labels-v2", cacheKeys["label"])
require.Equal(t, "inboxes-v2", cacheKeys["inbox"])
require.Equal(t, "teams-v2", cacheKeys["team"])
select {
case event := <-ch.Events:
require.Equal(t, EventAccountCacheInvalidated, event.Type)
fixture := event.Payload.(map[string]interface{})
require.Equal(t, payload["cache_keys"], fixture["cache_keys"])
case <-time.After(time.Second):
t.Fatal("SSE channel should receive account.cache_invalidated fixture")
}
}
// === Mock MessageHandler reuse ===
// The mockHandler type is defined in broadcast_test.go in the same package.
// We reuse it here for EventPublisher tests. If needed, here's a duplicate
@@ -231,7 +634,7 @@ func TestEventPublisher_WSMessageFormat(t *testing.T) {
// Additional mock for tracking room-based sends
type mockRoomHandler struct {
mu sync.Mutex
mu sync.Mutex
accounts map[uint][]byte
rooms map[string][]byte
}
@@ -283,4 +686,26 @@ func TestEventPublisher_ConversationEvent_RoomDelivery(t *testing.T) {
convRoom := conversationRoomNameHelper(1, 42)
roomData := hub.getRoomData(convRoom)
require.NotNil(t, roomData, "Hub conversation room should receive event")
}
}
func TestEventPublisher_WidgetEvent_PubsubTokenRoomDelivery(t *testing.T) {
hub := newMockRoomHandler()
sse := NewSSERegistry()
publisher := NewEventPublisherLocal(hub, sse)
payload := map[string]interface{}{"id": 7, "content": "hello widget"}
publisher.PublishWidgetEvent(1, "pubsub-123", EventMessageCreated, payload)
accountData := hub.getAccountData(1)
require.NotNil(t, accountData, "Hub account room should receive widget event")
var accountMsg WSMessage
require.NoError(t, json.Unmarshal(accountData, &accountMsg))
assert.Equal(t, EventMessageCreated, accountMsg.Event)
roomData := hub.getRoomData("pubsub_token_pubsub-123")
require.NotNil(t, roomData, "Hub pubsub_token room should receive widget event")
var roomMsg WSMessage
require.NoError(t, json.Unmarshal(roomData, &roomMsg))
assert.Equal(t, EventMessageCreated, roomMsg.Event)
assert.Equal(t, uint(1), roomMsg.AccountID)
}
+26 -24
View File
@@ -41,6 +41,7 @@ const (
EventContactUpdated = "contact.updated"
EventContactDeleted = "contact.deleted"
EventContactMerged = "contact.merged"
EventCompanyUpdated = "company.updated"
)
// Notification events
@@ -78,23 +79,24 @@ const (
// --- Protocol control event constants ---
const (
EventSubscribeConfirm = "subscribe.confirm"
EventUnsubscribeConfirm = "unsubscribe.confirm"
EventSubscribeReject = "subscribe.reject"
EventPingResponse = "ping.response"
EventWelcome = "welcome"
EventDisconnect = "disconnect"
EventSubscribeConfirm = "subscribe.confirm"
EventUnsubscribeConfirm = "unsubscribe.confirm"
EventSubscribeReject = "subscribe.reject"
EventPingResponse = "ping.response"
EventWelcome = "welcome"
EventDisconnect = "disconnect"
)
// --- Wire-format message types ---
// WSMessage is the JSON envelope for all server→client WebSocket messages.
// Mirrors Chatwoot's ActionCableBroadcastJob event format:
// {event: "message.created", data: {...}, account_id: 1, performer: {id, name, type, avatar_url}}
//
// {event: "message.created", data: {...}, account_id: 1, performer: {id, name, type, avatar_url}}
type WSMessage struct {
Event string `json:"event"` // e.g. "message.created"
Data any `json:"data"` // event payload
AccountID uint `json:"account_id,omitempty"` // target account
Performer *Performer `json:"performer,omitempty"` // who triggered the event
Event string `json:"event"` // e.g. "message.created"
Data any `json:"data"` // event payload
AccountID uint `json:"account_id,omitempty"` // target account
Performer *Performer `json:"performer,omitempty"` // who triggered the event
}
// Performer describes who triggered an event, mirroring Chatwoot's
@@ -102,24 +104,24 @@ type WSMessage struct {
type Performer struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "user" or "contact"
Type string `json:"type"` // "user" or "contact"
AvatarURL string `json:"avatar_url"`
}
// WSCommand is the JSON envelope for client→server WebSocket commands.
// Mirrors Chatwoot's ActionCable command structure.
type WSCommand struct {
Command string `json:"command"` // subscribe, unsubscribe, ping, typing_on, typing_off, update_presence
Data string `json:"data,omitempty"` // JSON-encoded command payload
Command string `json:"command"` // subscribe, unsubscribe, ping, typing_on, typing_off, update_presence
Data string `json:"data,omitempty"` // JSON-encoded command payload
}
// SubscribeData is the payload for a subscribe command.
// Mirrors Chatwoot RoomChannel identifier.
type SubscribeData struct {
Channel string `json:"channel"` // "AccountChannel" or "ConversationChannel"
AccountID uint `json:"account_id"` // required for both channels
PubsubToken string `json:"pubsub_token,omitempty"` // for contact-based auth
ConversationID uint `json:"conversation_id,omitempty"` // required for ConversationChannel
Channel string `json:"channel"` // "AccountChannel" or "ConversationChannel"
AccountID uint `json:"account_id"` // required for both channels
PubsubToken string `json:"pubsub_token,omitempty"` // for contact-based auth
ConversationID uint `json:"conversation_id,omitempty"` // required for ConversationChannel
}
// TypingData is the payload for typing_on/typing_off commands.
@@ -142,13 +144,13 @@ const (
// --- Redis key prefix constants ---
const (
// Pub/Sub channel prefixes for cross-instance relay
RedisPrefixRoom = "gochat:ws:room:" // gochat:ws:room:account_{id}
RedisPrefixAccount = "gochat:ws:account:" // gochat:ws:account:{id}
RedisPrefixRoom = "gochat:ws:room:" // gochat:ws:room:account_{id}
RedisPrefixAccount = "gochat:ws:account:" // gochat:ws:account:{id}
// Sorted set / hash keys for presence tracking
RedisKeyPresenceAgents = "gochat:presence:agents" // sorted set: score=timestamp, member=agent_id:account_id
RedisKeyPresenceContacts = "gochat:presence:contacts" // sorted set: score=timestamp, member=contact_id:account_id
RedisKeyPresenceStatus = "gochat:presence:status" // hash: field=id:account_id, value=status string
RedisKeyPresenceAgents = "gochat:presence:agents" // sorted set: score=timestamp, member=agent_id:account_id
RedisKeyPresenceContacts = "gochat:presence:contacts" // sorted set: score=timestamp, member=contact_id:account_id
RedisKeyPresenceStatus = "gochat:presence:status" // hash: field=id:account_id, value=status string
// Typing indicator keys with TTL
RedisKeyTyping = "gochat:typing:%d:%d" // gochat:typing:{account_id}:{conversation_id}
@@ -166,4 +168,4 @@ const (
// TypingTTLSec is how long a typing indicator persists before auto-expiry.
TypingTTLSec = 4
)
)
+106 -28
View File
@@ -59,15 +59,7 @@ func (p *PresenceTracker) SetAgentOnline(ctx context.Context, agentID, accountID
return fmt.Errorf("failed to set agent status: %w", err)
}
// Broadcast presence update event
msg := &WSMessage{
Event: EventAgentOnline,
Data: map[string]any{
"agent_id": agentID,
"account_id": accountID,
},
AccountID: accountID,
}
msg := presenceUpdateMessage(accountID, map[uint]string{agentID: "online"}, nil)
if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil {
logger.L().Warnf("ws presence: failed to broadcast agent online: %v", err)
@@ -94,15 +86,7 @@ func (p *PresenceTracker) SetAgentOffline(ctx context.Context, agentID, accountI
return fmt.Errorf("failed to remove agent status: %w", err)
}
// Broadcast offline event
msg := &WSMessage{
Event: EventAgentOffline,
Data: map[string]any{
"agent_id": agentID,
"account_id": accountID,
},
AccountID: accountID,
}
msg := presenceUpdateMessage(accountID, map[uint]string{agentID: "offline"}, nil)
if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil {
logger.L().Warnf("ws presence: failed to broadcast agent offline: %v", err)
@@ -129,16 +113,7 @@ func (p *PresenceTracker) SetAgentBusy(ctx context.Context, agentID, accountID u
return fmt.Errorf("failed to set agent status busy: %w", err)
}
// Broadcast presence update
msg := &WSMessage{
Event: EventPresenceUpdate,
Data: map[string]any{
"agent_id": agentID,
"account_id": accountID,
"status": "busy",
},
AccountID: accountID,
}
msg := presenceUpdateMessage(accountID, map[uint]string{agentID: "busy"}, nil)
if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil {
logger.L().Warnf("ws presence: failed to broadcast agent busy: %v", err)
@@ -164,6 +139,11 @@ func (p *PresenceTracker) SetContactOnline(ctx context.Context, contactID, accou
return fmt.Errorf("failed to set contact status: %w", err)
}
msg := presenceUpdateMessage(accountID, nil, map[uint]string{contactID: "online"})
if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil {
logger.L().Warnf("ws presence: failed to broadcast contact online: %v", err)
}
logger.L().Debugf("ws presence: contact %d online for account %d", contactID, accountID)
return nil
}
@@ -180,10 +160,33 @@ func (p *PresenceTracker) SetContactOffline(ctx context.Context, contactID, acco
return fmt.Errorf("failed to remove contact status: %w", err)
}
msg := presenceUpdateMessage(accountID, nil, map[uint]string{contactID: "offline"})
if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil {
logger.L().Warnf("ws presence: failed to broadcast contact offline: %v", err)
}
logger.L().Debugf("ws presence: contact %d offline for account %d", contactID, accountID)
return nil
}
func presenceUpdateMessage(accountID uint, users map[uint]string, contacts map[uint]string) *WSMessage {
if users == nil {
users = map[uint]string{}
}
if contacts == nil {
contacts = map[uint]string{}
}
return &WSMessage{
Event: EventPresenceUpdate,
Data: map[string]any{
"account_id": accountID,
"users": users,
"contacts": contacts,
},
AccountID: accountID,
}
}
// GetOnlineAgentsForAccount returns all currently online agent IDs for an account.
// Only returns agents whose sorted-set score is within PresenceDurationAgentSec
// of the current time (i.e., have sent a heartbeat recently).
@@ -255,6 +258,24 @@ func (p *PresenceTracker) CleanupExpired(ctx context.Context) error {
agentThreshold := float64(time.Now().Unix() - PresenceDurationAgentSec)
contactThreshold := float64(time.Now().Unix() - PresenceDurationContactSec)
expiredAgents, agentListErr := p.rdb.ZRangeByScore(ctx, RedisKeyPresenceAgents, &redis.ZRangeBy{
Min: "0",
Max: fmt.Sprintf("%f", agentThreshold),
}).Result()
if agentListErr != nil {
logger.L().Warnf("ws presence: failed to list expired agents: %v", agentListErr)
expiredAgents = nil
}
expiredContacts, contactListErr := p.rdb.ZRangeByScore(ctx, RedisKeyPresenceContacts, &redis.ZRangeBy{
Min: "0",
Max: fmt.Sprintf("%f", contactThreshold),
}).Result()
if contactListErr != nil {
logger.L().Warnf("ws presence: failed to list expired contacts: %v", contactListErr)
expiredContacts = nil
}
// Remove expired agents
removedAgents, err := p.rdb.ZRemRangeByScore(ctx, RedisKeyPresenceAgents,
"0", fmt.Sprintf("%f", agentThreshold)).Result()
@@ -269,6 +290,8 @@ func (p *PresenceTracker) CleanupExpired(ctx context.Context) error {
logger.L().Warnf("ws presence: failed to cleanup expired contacts: %v", err)
}
p.cleanupExpiredStatusesAndBroadcast(ctx, expiredAgents, expiredContacts)
if removedAgents > 0 || removedContacts > 0 {
logger.L().Infof("ws presence: cleanup removed %d agents, %d contacts",
removedAgents, removedContacts)
@@ -276,6 +299,61 @@ func (p *PresenceTracker) CleanupExpired(ctx context.Context) error {
return nil
}
func (p *PresenceTracker) cleanupExpiredStatusesAndBroadcast(ctx context.Context, expiredAgents, expiredContacts []string) {
updates := map[uint]struct {
users map[uint]string
contacts map[uint]string
}{}
addUpdate := func(accountID uint) struct {
users map[uint]string
contacts map[uint]string
} {
update := updates[accountID]
if update.users == nil {
update.users = map[uint]string{}
}
if update.contacts == nil {
update.contacts = map[uint]string{}
}
updates[accountID] = update
return update
}
for _, member := range expiredAgents {
agentID, accountID := parsePresenceMember(member)
if agentID == 0 || accountID == 0 {
continue
}
update := addUpdate(accountID)
update.users[agentID] = "offline"
updates[accountID] = update
if err := p.rdb.HDel(ctx, RedisKeyPresenceStatus, member).Err(); err != nil {
logger.L().Warnf("ws presence: failed to delete expired agent status %s: %v", member, err)
}
}
for _, member := range expiredContacts {
contactID, accountID := parsePresenceMember(member)
if contactID == 0 || accountID == 0 {
continue
}
update := addUpdate(accountID)
update.contacts[contactID] = "offline"
updates[accountID] = update
if err := p.rdb.HDel(ctx, RedisKeyPresenceStatus, member).Err(); err != nil {
logger.L().Warnf("ws presence: failed to delete expired contact status %s: %v", member, err)
}
}
for accountID, update := range updates {
msg := presenceUpdateMessage(accountID, update.users, update.contacts)
if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil {
logger.L().Warnf("ws presence: failed to broadcast expired presence for account %d: %v", accountID, err)
}
}
}
// RefreshAgentPresence refreshes an agent's timestamp in the sorted set.
// Called on each heartbeat to prevent the agent from being cleaned up.
func (p *PresenceTracker) RefreshAgentPresence(ctx context.Context, agentID, accountID uint) error {
+84
View File
@@ -2,6 +2,7 @@ package ws
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
@@ -187,6 +188,47 @@ func TestSetAgentBusy(t *testing.T) {
assert.Greater(t, score, 0.0, "busy agent 应仍在 sorted set 中")
}
func TestPresenceTracker_BroadcastsChatwootPresenceUpdatePayload(t *testing.T) {
_, rdb, tracker, _, cleanup := setupPresenceTest(t)
defer cleanup()
ctx := context.Background()
channel := RedisPrefixAccount + "5"
sub := rdb.Subscribe(ctx, channel)
defer sub.Close()
_, err := sub.Receive(ctx)
require.NoError(t, err)
require.NoError(t, tracker.SetAgentBusy(ctx, 10, 5))
msgCh := sub.Channel()
select {
case redisMsg := <-msgCh:
assert.Equal(t, channel, redisMsg.Channel)
var wsMsg WSMessage
require.NoError(t, json.Unmarshal([]byte(redisMsg.Payload), &wsMsg))
assert.Equal(t, EventPresenceUpdate, wsMsg.Event)
data := wsMsg.Data.(map[string]interface{})
assert.Equal(t, float64(5), data["account_id"])
users := data["users"].(map[string]interface{})
assert.Equal(t, "busy", users["10"])
case <-time.After(2 * time.Second):
t.Fatal("should receive agent presence.update payload")
}
require.NoError(t, tracker.SetContactOnline(ctx, 99, 5))
select {
case redisMsg := <-msgCh:
var wsMsg WSMessage
require.NoError(t, json.Unmarshal([]byte(redisMsg.Payload), &wsMsg))
assert.Equal(t, EventPresenceUpdate, wsMsg.Event)
data := wsMsg.Data.(map[string]interface{})
contacts := data["contacts"].(map[string]interface{})
assert.Equal(t, "online", contacts["99"])
case <-time.After(2 * time.Second):
t.Fatal("should receive contact presence.update payload")
}
}
// --- SetContactOnline / SetContactOffline 测试 ---
func TestSetContactOnline(t *testing.T) {
@@ -466,6 +508,48 @@ func TestCleanupExpired(t *testing.T) {
assert.Equal(t, int64(0), contactCount, "过期 contact 应被清理")
}
func TestCleanupExpired_BroadcastsChatwootOfflinePayloadAndClearsStatus(t *testing.T) {
_, rdb, tracker, _, cleanup := setupPresenceTest(t)
defer cleanup()
ctx := context.Background()
channel := RedisPrefixAccount + "10"
sub := rdb.Subscribe(ctx, channel)
defer sub.Close()
_, err := sub.Receive(ctx)
require.NoError(t, err)
expiredAgentScore := float64(time.Now().Unix() - PresenceDurationAgentSec - 10)
expiredContactScore := float64(time.Now().Unix() - PresenceDurationContactSec - 10)
require.NoError(t, rdb.ZAdd(ctx, RedisKeyPresenceAgents, redis.Z{Score: expiredAgentScore, Member: "1:10"}).Err())
require.NoError(t, rdb.HSet(ctx, RedisKeyPresenceStatus, "1:10", "busy").Err())
require.NoError(t, rdb.ZAdd(ctx, RedisKeyPresenceContacts, redis.Z{Score: expiredContactScore, Member: "100:10"}).Err())
require.NoError(t, rdb.HSet(ctx, RedisKeyPresenceStatus, "100:10", "online").Err())
require.NoError(t, tracker.CleanupExpired(ctx))
select {
case redisMsg := <-sub.Channel():
require.Equal(t, channel, redisMsg.Channel)
var wsMsg WSMessage
require.NoError(t, json.Unmarshal([]byte(redisMsg.Payload), &wsMsg))
require.Equal(t, EventPresenceUpdate, wsMsg.Event)
data := wsMsg.Data.(map[string]interface{})
require.Equal(t, float64(10), data["account_id"])
users := data["users"].(map[string]interface{})
require.Equal(t, "offline", users["1"])
contacts := data["contacts"].(map[string]interface{})
require.Equal(t, "offline", contacts["100"])
case <-time.After(2 * time.Second):
t.Fatal("should receive presence.update offline payload for expired records")
}
_, err = rdb.HGet(ctx, RedisKeyPresenceStatus, "1:10").Result()
require.Equal(t, redis.Nil, err)
_, err = rdb.HGet(ctx, RedisKeyPresenceStatus, "100:10").Result()
require.Equal(t, redis.Nil, err)
}
func TestCleanupExpired_NoExpired(t *testing.T) {
_, _, tracker, _, cleanup := setupPresenceTest(t)
defer cleanup()