Files
gochat/internal/pubsub/event_bus.go
T
2026-06-04 15:44:48 +08:00

204 lines
7.3 KiB
Go

package pubsub
import (
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill-redisstream/pkg/redisstream"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/redis/go-redis/v9"
"github.com/gochat/gochat/pkg/logger"
)
// Reference: P2E §3 — Real-time communication via Redis Pub/Sub
// Using Watermill as the unified message bus, replacing Chatwoot's Wisper + Sidekiq pattern.
//
// Architecture mapping:
// Chatwoot Wisper (sync broadcast) → Watermill Router (sync handler)
// Chatwoot Sidekiq (async workers) → Watermill Redis Streams (persistent async)
// Chatwoot ActionCable (WebSocket) → Watermill subscriber → WebSocket gateway
//
// Watermill advantages over asynq:
// - Multiple pub/sub backends (Redis Streams, Kafka, AMQP, SQL, Google Cloud Pub/Sub)
// - Built-in Router for event routing (like Chatwoot's Wisper event names)
// - CQRS component for command/query separation
// - At-least-once delivery with ack/nack semantics
// - Consumer groups for horizontal scaling
// --- Topic Constants ---
// Topics follow Chatwoot's event naming convention with GoChat-specific prefixes.
// Format: gochat.{domain}.{event_type}
const (
// Message topics (ref: Chatwoot MessageCreated/MessageUpdated events)
TopicMessageCreated = "gochat.message.created"
TopicMessageUpdated = "gochat.message.updated"
TopicMessageDeleted = "gochat.message.deleted"
// Conversation topics (ref: Chatwoot ConversationStatusChanged events)
TopicConversationCreated = "gochat.conversation.created"
TopicConversationUpdated = "gochat.conversation.updated"
TopicConversationResolved = "gochat.conversation.resolved"
TopicConversationAssigned = "gochat.conversation.assigned"
// 1:1 Chatwoot: additional conversation events
TopicConversationStatusChanged = "gochat.conversation.status_changed"
TopicConversationContactChanged = "gochat.conversation.contact_changed"
TopicConversationRead = "gochat.conversation.read"
TopicConversationUnreadCountChanged = "gochat.conversation.unread_count_changed"
TopicConversationMentioned = "gochat.conversation.mentioned"
TopicAssigneeChanged = "gochat.conversation.assignee_changed"
TopicTeamChanged = "gochat.conversation.team_changed"
TopicFirstReplyCreated = "gochat.message.first_reply_created"
// Contact topics (ref: Chatwoot ContactCreated/ContactUpdated events)
TopicContactCreated = "gochat.contact.created"
TopicContactUpdated = "gochat.contact.updated"
// 1:1 Chatwoot: additional contact events
TopicContactMerged = "gochat.contact.merged"
TopicContactDeleted = "gochat.contact.deleted"
// Channel event topics (ref: Chatwoot webhook events per channel)
TopicChannelEvent = "gochat.channel.event.%s" // %s = channel_type (telegram, web_widget, etc.)
// Inbox topics
TopicInboxCreated = "gochat.inbox.created"
TopicInboxUpdated = "gochat.inbox.updated"
TopicInboxDeleted = "gochat.inbox.deleted"
// Agent topics (ref: Chatwoot Agent events)
TopicAgentTypingOn = "gochat.agent.typing_on"
TopicAgentTypingOff = "gochat.agent.typing_off"
// System topics
TopicWebhookReceived = "gochat.webhook.received"
TopicSystemNotification = "gochat.system.notification"
TopicNotificationCreated = "gochat.notification.created"
TopicNotificationDeleted = "gochat.notification.deleted"
// Account topics
TopicAccountCacheInvalidated = "gochat.account.cache_invalidated"
// AccountUser topics — 1:1 Chatwoot: after_create_commit/after_destroy callbacks
TopicAccountUserCreated = "gochat.account_user.created"
TopicAccountUserUpdated = "gochat.account_user.updated"
TopicAccountUserDestroyed = "gochat.account_user.destroyed"
)
// FormatTopic creates a topic string with a dynamic suffix.
// Example: FormatTopic(TopicChannelEvent, "telegram") → "gochat.channel.event.telegram"
func FormatTopic(template, suffix string) string {
result := make([]byte, 0, len(template)+len(suffix))
i := 0
for i < len(template) {
if i+1 < len(template) && template[i] == '%' && template[i+1] == 's' {
result = append(result, suffix...)
i += 2
} else {
result = append(result, template[i])
i++
}
}
return string(result)
}
// --- Watermill Adapter ---
// EventBus wraps Watermill publisher for application-level event publishing.
// This is the GoChat equivalent of Chatwoot's Wisper.broadcast().
type EventBus struct {
publisher message.Publisher
}
// NewEventBus creates an EventBus backed by Redis Streams via Watermill.
func NewEventBus(redisClient redis.UniversalClient) (*EventBus, error) {
loggerAdapter := &zapLoggerAdapter{}
publisher, err := redisstream.NewPublisher(
redisstream.PublisherConfig{
Client: redisClient,
},
loggerAdapter,
)
if err != nil {
return nil, err
}
return &EventBus{publisher: publisher}, nil
}
// Publish publishes an event to a topic.
// Payload should be JSON-encoded event data.
func (bus *EventBus) Publish(topic string, payload []byte) error {
msg := message.NewMessage(watermill.NewUUID(), payload)
msg.Metadata.Set("topic", topic)
msg.Metadata.Set("source", "gochat")
if err := bus.publisher.Publish(topic, msg); err != nil {
logger.L().Errorf("failed to publish event to topic %s: %v", topic, err)
return err
}
logger.L().Infof("published event to topic %s (msg_id=%s)", topic, msg.UUID)
return nil
}
// Close shuts down the publisher.
func (bus *EventBus) Close() error {
return bus.publisher.Close()
}
// --- Subscriber Factory ---
// NewSubscriber creates a Watermill Redis Streams subscriber for a consumer group.
// Each GoChat service instance runs as a separate consumer within the group,
// enabling horizontal scaling (same pattern as Chatwoot's Sidekiq workers).
func NewSubscriber(redisClient redis.UniversalClient, consumerGroup string) (*redisstream.Subscriber, error) {
loggerAdapter := &zapLoggerAdapter{}
subscriber, err := redisstream.NewSubscriber(
redisstream.SubscriberConfig{
Client: redisClient,
ConsumerGroup: consumerGroup,
BlockTime: redisstream.DefaultBlockTime,
ClaimInterval: redisstream.DefaultClaimInterval,
ClaimBatchSize: redisstream.DefaultClaimBatchSize,
MaxIdleTime: redisstream.DefaultMaxIdleTime,
CheckConsumersInterval: redisstream.DefaultCheckConsumersInterval,
ConsumerTimeout: redisstream.DefaultConsumerTimeout,
},
loggerAdapter,
)
if err != nil {
return nil, err
}
return subscriber, nil
}
// --- Zap Logger Adapter ---
// Bridges GoChat's zap.SugaredLogger to Watermill's LoggerAdapter interface.
type zapLoggerAdapter struct{}
func (l *zapLoggerAdapter) Error(msg string, err error, fields watermill.LogFields) {
if err != nil {
logger.L().Errorf("watermill: %s err=%v fields=%v", msg, err, fields)
} else {
logger.L().Errorf("watermill: %s fields=%v", msg, fields)
}
}
func (l *zapLoggerAdapter) Info(msg string, fields watermill.LogFields) {
logger.L().Infof("watermill: %s fields=%v", msg, fields)
}
func (l *zapLoggerAdapter) Debug(msg string, fields watermill.LogFields) {
// Skip debug logs to reduce noise in production
}
func (l *zapLoggerAdapter) Trace(msg string, fields watermill.LogFields) {
// Skip trace logs
}
func (l *zapLoggerAdapter) With(fields watermill.LogFields) watermill.LoggerAdapter {
return l // No field enrichment needed — zap handles structured logging via .L()
}