Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// Subscriber bridges Watermill PubSub events to WebSocket push notifications.
|
||||
// It subscribes to relevant Watermill topics (message, conversation, contact events)
|
||||
// and forwards the events to the appropriate WebSocket rooms via the Hub.
|
||||
//
|
||||
// Architecture mapping:
|
||||
//
|
||||
// Chatwoot ActionCable broadcasts → Watermill subscriber → Hub.SendToAccount/SendToConversationJSON
|
||||
// Each GoChat instance runs its own subscriber, so events are delivered to
|
||||
// locally-connected WebSocket clients. Multi-instance delivery relies on Redis
|
||||
// PubSub (each instance receives the event and pushes to its own clients).
|
||||
//
|
||||
// Reference: P2E §3 — Real-time communication via Redis Pub/Sub
|
||||
type Subscriber struct {
|
||||
hub *Hub
|
||||
subscriber *redisstream.Subscriber
|
||||
router *message.Router
|
||||
redisClient redis.UniversalClient
|
||||
}
|
||||
|
||||
// NewSubscriber creates a PubSub-to-WebSocket bridge subscriber.
|
||||
func NewSubscriber(hub *Hub, redisClient redis.UniversalClient) (*Subscriber, error) {
|
||||
loggerAdapter := &watermillZapAdapter{}
|
||||
|
||||
wmSubscriber, err := redisstream.NewSubscriber(
|
||||
redisstream.SubscriberConfig{
|
||||
Client: redisClient,
|
||||
ConsumerGroup: "ws-gateway-" + watermill.NewUUID(),
|
||||
},
|
||||
loggerAdapter,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create watermill subscriber: %w", err)
|
||||
}
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, loggerAdapter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create watermill router: %w", err)
|
||||
}
|
||||
|
||||
s := &Subscriber{
|
||||
hub: hub,
|
||||
subscriber: wmSubscriber,
|
||||
router: router,
|
||||
redisClient: redisClient,
|
||||
}
|
||||
|
||||
// Register handler functions for each event topic
|
||||
s.registerHandlers()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// registerHandlers sets up Watermill router handlers that forward PubSub events
|
||||
// to WebSocket clients via the Hub.
|
||||
func (s *Subscriber) registerHandlers() {
|
||||
// --- Message events → Account room + Conversation room ---
|
||||
// Using AddNoPublisherHandler because these handlers push directly to WS, not to an output topic.
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-message-created-handler",
|
||||
"gochat.message.created",
|
||||
s.subscriber,
|
||||
s.forwardToAccountAndConversation(EventMessageCreated),
|
||||
)
|
||||
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-message-updated-handler",
|
||||
"gochat.message.updated",
|
||||
s.subscriber,
|
||||
s.forwardToAccountAndConversation(EventMessageUpdated),
|
||||
)
|
||||
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-message-deleted-handler",
|
||||
"gochat.message.deleted",
|
||||
s.subscriber,
|
||||
s.forwardToAccountAndConversation(EventMessageDeleted),
|
||||
)
|
||||
|
||||
// --- Conversation events → Account room ---
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-conversation-created-handler",
|
||||
"gochat.conversation.created",
|
||||
s.subscriber,
|
||||
s.forwardToAccount(EventConversationCreated),
|
||||
)
|
||||
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-conversation-updated-handler",
|
||||
"gochat.conversation.updated",
|
||||
s.subscriber,
|
||||
s.forwardToAccountAndConversation(EventConversationUpdated),
|
||||
)
|
||||
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-conversation-resolved-handler",
|
||||
"gochat.conversation.resolved",
|
||||
s.subscriber,
|
||||
s.forwardToAccountAndConversation(EventConversationResolved),
|
||||
)
|
||||
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-conversation-assigned-handler",
|
||||
"gochat.conversation.assigned",
|
||||
s.subscriber,
|
||||
s.forwardToAccountAndConversation(EventConversationAssigned),
|
||||
)
|
||||
|
||||
// --- Contact events → Account room ---
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-contact-created-handler",
|
||||
"gochat.contact.created",
|
||||
s.subscriber,
|
||||
s.forwardToAccount(EventContactCreated),
|
||||
)
|
||||
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-contact-updated-handler",
|
||||
"gochat.contact.updated",
|
||||
s.subscriber,
|
||||
s.forwardToAccount(EventContactUpdated),
|
||||
)
|
||||
|
||||
// --- Agent typing events → Conversation room ---
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-agent-typing-on-handler",
|
||||
"gochat.agent.typing_on",
|
||||
s.subscriber,
|
||||
s.forwardToConversation(EventAgentTypingOn),
|
||||
)
|
||||
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-agent-typing-off-handler",
|
||||
"gochat.agent.typing_off",
|
||||
s.subscriber,
|
||||
s.forwardToConversation(EventAgentTypingOff),
|
||||
)
|
||||
|
||||
// --- Inbox events → Account room ---
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-inbox-created-handler",
|
||||
"gochat.inbox.created",
|
||||
s.subscriber,
|
||||
s.forwardToAccount(EventInboxCreated),
|
||||
)
|
||||
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-inbox-updated-handler",
|
||||
"gochat.inbox.updated",
|
||||
s.subscriber,
|
||||
s.forwardToAccount(EventInboxUpdated),
|
||||
)
|
||||
|
||||
// --- Notification events → Account room (P4 M8 Notification+Webhook) ---
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-notification-created-handler",
|
||||
"gochat.notification.created",
|
||||
s.subscriber,
|
||||
s.forwardToAccount(EventNotificationCreated),
|
||||
)
|
||||
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-notification-updated-handler",
|
||||
"gochat.notification.updated",
|
||||
s.subscriber,
|
||||
s.forwardToAccount(EventNotificationUpdated),
|
||||
)
|
||||
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-notification-deleted-handler",
|
||||
"gochat.notification.deleted",
|
||||
s.subscriber,
|
||||
s.forwardToAccount(EventNotificationDeleted),
|
||||
)
|
||||
|
||||
// --- Account cache events → Account room ---
|
||||
s.router.AddNoPublisherHandler(
|
||||
"ws-account-cache-invalidated-handler",
|
||||
"gochat.account.cache_invalidated",
|
||||
s.subscriber,
|
||||
s.forwardToAccount(EventAccountCacheInvalidated),
|
||||
)
|
||||
}
|
||||
|
||||
// forwardToAccountAndConversation creates a handler that pushes an event to both
|
||||
// the account room and the conversation room (if the payload contains conversation_id).
|
||||
// Used for message events and conversation update events that are relevant at both levels.
|
||||
func (s *Subscriber) forwardToAccountAndConversation(eventType string) func(msg *message.Message) error {
|
||||
return func(msg *message.Message) error {
|
||||
payload, err := extractPayload(msg)
|
||||
if err != nil {
|
||||
logger.L().Errorf("ws: failed to extract payload for %s: %v", eventType, err)
|
||||
return nil // don't retry — bad payload
|
||||
}
|
||||
|
||||
accountID := payload.AccountID
|
||||
if accountID == 0 {
|
||||
logger.L().Warnf("ws: event %s missing account_id", eventType)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build the event frame
|
||||
frame := EventFrame{
|
||||
Type: ServerEvent,
|
||||
Event: eventType,
|
||||
Payload: payload.Data,
|
||||
}
|
||||
frameData, err := json.Marshal(frame)
|
||||
if err != nil {
|
||||
logger.L().Errorf("ws: failed to marshal event frame for %s: %v", eventType, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Push to account room
|
||||
s.hub.SendToAccount(accountID, frameData)
|
||||
|
||||
// Also push to conversation room if conversation_id is present
|
||||
if payload.ConversationID > 0 {
|
||||
s.hub.SendToAccountConversation(accountID, payload.ConversationID, frameData)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// forwardToAccount creates a handler that pushes an event only to the account room.
|
||||
// Used for conversation/contact/inbox creation events that don't target a specific conversation.
|
||||
func (s *Subscriber) forwardToAccount(eventType string) func(msg *message.Message) error {
|
||||
return func(msg *message.Message) error {
|
||||
payload, err := extractPayload(msg)
|
||||
if err != nil {
|
||||
logger.L().Errorf("ws: failed to extract payload for %s: %v", eventType, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
accountID := payload.AccountID
|
||||
if accountID == 0 {
|
||||
logger.L().Warnf("ws: event %s missing account_id", eventType)
|
||||
return nil
|
||||
}
|
||||
|
||||
frame := EventFrame{
|
||||
Type: ServerEvent,
|
||||
Event: eventType,
|
||||
Payload: payload.Data,
|
||||
}
|
||||
frameData, err := json.Marshal(frame)
|
||||
if err != nil {
|
||||
logger.L().Errorf("ws: failed to marshal event frame for %s: %v", eventType, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
s.hub.SendToAccount(accountID, frameData)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// forwardToConversation creates a handler that pushes an event only to the conversation room.
|
||||
// Used for typing events that only need to reach clients viewing that conversation.
|
||||
func (s *Subscriber) forwardToConversation(eventType string) func(msg *message.Message) error {
|
||||
return func(msg *message.Message) error {
|
||||
payload, err := extractPayload(msg)
|
||||
if err != nil {
|
||||
logger.L().Errorf("ws: failed to extract payload for %s: %v", eventType, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
accountID := payload.AccountID
|
||||
conversationID := payload.ConversationID
|
||||
if accountID == 0 || conversationID == 0 {
|
||||
logger.L().Warnf("ws: event %s missing account_id or conversation_id", eventType)
|
||||
return nil
|
||||
}
|
||||
|
||||
frame := EventFrame{
|
||||
Type: ServerEvent,
|
||||
Event: eventType,
|
||||
Payload: payload.Data,
|
||||
}
|
||||
frameData, err := json.Marshal(frame)
|
||||
if err != nil {
|
||||
logger.L().Errorf("ws: failed to marshal event frame for %s: %v", eventType, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
s.hub.SendToAccountConversation(accountID, conversationID, frameData)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event Payload Extraction ---
|
||||
|
||||
// EventPayload represents the common structure of PubSub event messages.
|
||||
// Watermill message payloads are JSON-encoded with account_id, conversation_id,
|
||||
// and an arbitrary data field.
|
||||
type EventPayload struct {
|
||||
AccountID uint `json:"account_id"`
|
||||
ConversationID uint `json:"conversation_id,omitempty"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// extractPayload parses a Watermill message payload into an EventPayload.
|
||||
func extractPayload(msg *message.Message) (*EventPayload, error) {
|
||||
var payload EventPayload
|
||||
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
|
||||
return nil, fmt.Errorf("json unmarshal failed: %w", err)
|
||||
}
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
// --- Watermill Zap Logger Adapter ---
|
||||
// Reuses the same pattern as pubsub/event_bus.go's zapLoggerAdapter.
|
||||
|
||||
type watermillZapAdapter struct{}
|
||||
|
||||
func (l *watermillZapAdapter) Error(msg string, err error, fields watermill.LogFields) {
|
||||
if err != nil {
|
||||
logger.L().Errorf("watermill-ws: %s err=%v fields=%v", msg, err, fields)
|
||||
} else {
|
||||
logger.L().Errorf("watermill-ws: %s fields=%v", msg, fields)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *watermillZapAdapter) Info(msg string, fields watermill.LogFields) {
|
||||
logger.L().Infof("watermill-ws: %s fields=%v", msg, fields)
|
||||
}
|
||||
|
||||
func (l *watermillZapAdapter) Debug(msg string, fields watermill.LogFields) {
|
||||
// Skip debug logs to reduce noise
|
||||
}
|
||||
|
||||
func (l *watermillZapAdapter) Trace(msg string, fields watermill.LogFields) {
|
||||
// Skip trace logs
|
||||
}
|
||||
|
||||
func (l *watermillZapAdapter) With(fields watermill.LogFields) watermill.LoggerAdapter {
|
||||
return l
|
||||
}
|
||||
|
||||
// --- Lifecycle Management ---
|
||||
|
||||
// Run starts the Watermill router (blocking). Should be called in a goroutine.
|
||||
func (s *Subscriber) Run(ctx context.Context) error {
|
||||
logger.L().Info("ws: starting PubSub subscriber router")
|
||||
return s.router.Run(ctx)
|
||||
}
|
||||
|
||||
// Close gracefully shuts down the Watermill router and subscriber.
|
||||
func (s *Subscriber) Close() error {
|
||||
logger.L().Info("ws: shutting down PubSub subscriber")
|
||||
if err := s.router.Close(); err != nil {
|
||||
logger.L().Errorf("ws: router close error: %v", err)
|
||||
return err
|
||||
}
|
||||
if err := s.subscriber.Close(); err != nil {
|
||||
if !strings.Contains(err.Error(), "already closed") {
|
||||
logger.L().Errorf("ws: subscriber close error: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Running returns whether the router is currently running.
|
||||
func (s *Subscriber) Running() bool {
|
||||
return s.router.IsRunning()
|
||||
}
|
||||
Reference in New Issue
Block a user