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:
2026-07-07 14:44:12 +08:00
parent d4ef996f49
commit aeddedf2a3
1348 changed files with 176 additions and 57 deletions
+197
View File
@@ -0,0 +1,197 @@
package dispatch
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/worker"
applogger "github.com/gochat/gochat/pkg/logger"
)
const TaskTypeEventListenerDispatch = "event:listener_dispatch"
// EventDispatcher wraps the channel.Dispatcher and adds:
// - Sync/async split: sync listeners run immediately; async listeners are queued
// - Event name routing: listeners subscribe to specific event names
// - Singleton pattern: one EventDispatcher per application
//
// Reference: Chatwoot Dispatcher pattern
// - Chatwoot::Dispatcher is a singleton that dispatches events to SyncDispatcher
// (immediate) and AsyncDispatcher (Sidekiq job).
// - SyncDispatcher listeners: ActionCableListener, AgentBotListener
// - AsyncDispatcher listeners: AutomationRuleListener, CampaignListener,
// CsatSurveyListener, HookListener, InstallationWebhookListener,
// NotificationListener, ParticipationListener, ReportingEventListener, WebhookListener
//
// In gochat, we wrap the existing channel.Dispatcher to preserve its simple
// EventListener interface and add the sync/async + event-name-routing layer.
// ListenerMode determines whether a listener runs synchronously or asynchronously.
type ListenerMode int
const (
// SyncMode means the listener runs in the same goroutine as the dispatcher call.
// Use for lightweight, latency-sensitive listeners (e.g. ActionCable, AgentBot).
SyncMode ListenerMode = iota
// AsyncMode means the listener runs in a background goroutine.
// Use for heavier listeners (e.g. AutomationRule, Notification, Webhook).
AsyncMode
)
// listenerEntry tracks a registered listener together with its mode and
// the event names it subscribes to. If eventNames is empty, the listener
// receives all events (wildcard).
type listenerEntry struct {
listener channel.EventListener
mode ListenerMode
eventNames []string // empty = wildcard (all events)
}
// EventDispatcher is the enhanced central dispatch hub.
// It wraps a channel.Dispatcher and adds sync/async routing and
// event-name filtering.
type EventDispatcher struct {
channelDispatcher *channel.Dispatcher
registry *ListenerRegistry
entries map[string]listenerEntry // listener name → entry
worker *worker.WorkerPool
}
// NewEventDispatcher creates a new EventDispatcher wrapping the given
// channel.Dispatcher.
func NewEventDispatcher(cd *channel.Dispatcher) *EventDispatcher {
return &EventDispatcher{
channelDispatcher: cd,
registry: NewListenerRegistry(),
entries: make(map[string]listenerEntry),
}
}
func (ed *EventDispatcher) SetWorkerPool(wp *worker.WorkerPool) {
ed.worker = wp
if wp != nil {
wp.Register(TaskTypeEventListenerDispatch, ed.performListenerJob)
}
}
// RegisterSync adds a sync-mode listener for the given event names.
// If eventNames is empty, the listener receives all events.
func (ed *EventDispatcher) RegisterSync(listener channel.EventListener, eventNames ...string) {
ed.entries[listener.Name()] = listenerEntry{
listener: listener,
mode: SyncMode,
eventNames: eventNames,
}
ed.registry.Subscribe(listener.Name(), eventNames...)
// Also register with the underlying channel.Dispatcher so Dispatch() calls work.
ed.channelDispatcher.Register(listener)
}
// RegisterAsync adds an async-mode listener for the given event names.
// If eventNames is empty, the listener receives all events.
func (ed *EventDispatcher) RegisterAsync(listener channel.EventListener, eventNames ...string) {
ed.entries[listener.Name()] = listenerEntry{
listener: listener,
mode: AsyncMode,
eventNames: eventNames,
}
ed.registry.Subscribe(listener.Name(), eventNames...)
ed.channelDispatcher.Register(listener)
}
// Dispatch sends an event to all matching listeners.
// Sync listeners run immediately in the calling goroutine.
// Async listeners are dispatched via a background goroutine.
// Returns the first error encountered from sync listeners (async errors are logged only).
func (ed *EventDispatcher) Dispatch(ctx context.Context, event *channel.ChannelEvent) error {
subscribers := ed.registry.SubscribersForEvent(string(event.Type))
var firstSyncErr error
for _, name := range subscribers {
entry, ok := ed.entries[name]
if !ok {
continue
}
if entry.mode == SyncMode {
if err := entry.listener.OnEvent(ctx, event); err != nil {
applogger.L().Errorf("sync listener %s error on event %s: %v", entry.listener.Name(), event.Type, err)
if firstSyncErr == nil {
firstSyncErr = err
}
}
} else {
ed.dispatchListenerAsync(ctx, entry.listener.Name(), event, "async listener")
}
}
return firstSyncErr
}
// DispatchAsync is a convenience method that always dispatches in async mode,
// running all listeners (both sync and async registrations) in background goroutines.
// This is useful for fire-and-forget event publishing.
func (ed *EventDispatcher) DispatchAsync(ctx context.Context, event *channel.ChannelEvent) {
subscribers := ed.registry.SubscribersForEvent(string(event.Type))
for _, name := range subscribers {
entry, ok := ed.entries[name]
if !ok {
continue
}
ed.dispatchListenerAsync(ctx, entry.listener.Name(), event, "async dispatch")
}
}
type listenerJobPayload struct {
ListenerName string `json:"listener_name"`
Event *channel.ChannelEvent `json:"event"`
}
func (ed *EventDispatcher) dispatchListenerAsync(ctx context.Context, listenerName string, event *channel.ChannelEvent, logPrefix string) {
if ed.worker != nil {
payload := listenerJobPayload{ListenerName: listenerName, Event: event}
if _, err := ed.worker.Enqueue(ctx, TaskTypeEventListenerDispatch, payload, worker.WithQueue("events"), worker.WithMaxAttempts(3)); err != nil {
applogger.L().Errorf("%s %s enqueue error on event %s: %v", logPrefix, listenerName, event.Type, err)
}
return
}
entry := ed.entries[listenerName]
go func(l channel.EventListener, e *channel.ChannelEvent) {
asyncCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := l.OnEvent(asyncCtx, e); err != nil {
applogger.L().Errorf("%s: listener %s error on event %s: %v", logPrefix, l.Name(), e.Type, err)
}
}(entry.listener, event)
}
func (ed *EventDispatcher) performListenerJob(ctx context.Context, job *model.BackgroundJob) error {
var payload listenerJobPayload
if err := json.Unmarshal(job.Payload, &payload); err != nil {
return fmt.Errorf("unmarshal listener dispatch payload: %w", err)
}
entry, ok := ed.entries[payload.ListenerName]
if !ok {
return fmt.Errorf("listener %q not registered", payload.ListenerName)
}
if payload.Event == nil {
return fmt.Errorf("listener %q job missing event", payload.ListenerName)
}
return entry.listener.OnEvent(ctx, payload.Event)
}
// ChannelDispatcher returns the underlying channel.Dispatcher for direct access
// if needed (e.g. for channel-level dispatch without the enhanced routing).
func (ed *EventDispatcher) ChannelDispatcher() *channel.Dispatcher {
return ed.channelDispatcher
}
// Registry returns the listener registry for inspection/testing.
func (ed *EventDispatcher) Registry() *ListenerRegistry {
return ed.registry
}
@@ -0,0 +1,107 @@
package dispatch
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/worker"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type dispatchWorkerListener struct {
name string
count atomic.Int32
}
func (l *dispatchWorkerListener) Name() string { return l.name }
func (l *dispatchWorkerListener) OnEvent(ctx context.Context, event *channel.ChannelEvent) error {
l.count.Add(1)
return nil
}
func newDispatchWorkerDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:dispatch-worker?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("sqlite db handle: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&model.BackgroundJob{}); err != nil {
t.Fatalf("migrate background jobs: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM background_jobs")
sqlDB.Close()
})
return db
}
func TestEventDispatcherQueuesAsyncListenersDurably(t *testing.T) {
db := newDispatchWorkerDB(t)
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return time.Date(2026, 6, 5, 11, 30, 0, 0, time.UTC) }))
ed := NewEventDispatcher(channel.NewDispatcher())
ed.SetWorkerPool(wp)
syncListener := &dispatchWorkerListener{name: "sync-listener"}
asyncListener := &dispatchWorkerListener{name: "async-listener"}
ed.RegisterSync(syncListener, string(channel.EventConversationCreated))
ed.RegisterAsync(asyncListener, string(channel.EventConversationCreated))
event := channel.NewChannelEvent(channel.EventConversationCreated, channel.ChannelWebWidget, 1, 2)
if err := ed.Dispatch(context.Background(), event); err != nil {
t.Fatalf("dispatch: %v", err)
}
if syncListener.count.Load() != 1 {
t.Fatalf("sync listener should run immediately")
}
if asyncListener.count.Load() != 0 {
t.Fatalf("async listener should wait for durable worker")
}
var count int64
if err := db.Model(&model.BackgroundJob{}).Where("job_type = ? AND status = ?", TaskTypeEventListenerDispatch, model.BackgroundJobStatusQueued).Count(&count).Error; err != nil {
t.Fatalf("count jobs: %v", err)
}
if count != 1 {
t.Fatalf("expected one queued listener dispatch job, got %d", count)
}
processed, err := wp.ProcessOne(context.Background())
if err != nil || !processed {
t.Fatalf("process listener job: processed=%v err=%v", processed, err)
}
if asyncListener.count.Load() != 1 {
t.Fatalf("async listener was not called by worker")
}
}
func TestEventDispatcherDispatchAsyncQueuesAllMatchingListeners(t *testing.T) {
db := newDispatchWorkerDB(t)
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return time.Date(2026, 6, 5, 11, 45, 0, 0, time.UTC) }))
ed := NewEventDispatcher(channel.NewDispatcher())
ed.SetWorkerPool(wp)
one := &dispatchWorkerListener{name: "one"}
two := &dispatchWorkerListener{name: "two"}
ed.RegisterSync(one, string(channel.EventMessageCreated))
ed.RegisterAsync(two, string(channel.EventMessageCreated))
ed.DispatchAsync(context.Background(), channel.NewChannelEvent(channel.EventMessageCreated, channel.ChannelAPI, 1, 2))
for i := 0; i < 2; i++ {
processed, err := wp.ProcessOne(context.Background())
if err != nil || !processed {
t.Fatalf("process listener job %d: processed=%v err=%v", i, processed, err)
}
}
if one.count.Load() != 1 || two.count.Load() != 1 {
t.Fatalf("expected both listeners via durable jobs, got one=%d two=%d", one.count.Load(), two.count.Load())
}
}
+155
View File
@@ -0,0 +1,155 @@
package dispatch
// Event types and helper functions for the enhanced dispatch system.
//
// Reference: Chatwoot event naming conventions
// - Chatwoot uses Wisper-style event names like "conversation.created",
// "message.created", "contact.created", etc.
// - In gochat, channel.EventType already defines these constants.
// - This file provides additional event types specific to the dispatch layer
// and helper functions for event construction.
import (
"fmt"
"time"
"github.com/gochat/gochat/internal/channel"
)
// DispatchEvent defines additional event types specific to the dispatch layer.
// These complement the channel.EventType constants already defined in
// channel/event.go.
type DispatchEvent string
const (
// Auto-assignment events (triggered by autoassignment listener)
DispatchEventAutoAssign DispatchEvent = "auto_assignment.assign"
DispatchEventAutoAssignFailed DispatchEvent = "auto_assignment.assign_failed"
DispatchEventAutoAssignSkipped DispatchEvent = "auto_assignment.assign_skipped"
// Reporting events (for analytics and reporting)
DispatchEventReportGenerated DispatchEvent = "report.generated"
DispatchEventReportFailed DispatchEvent = "report.failed"
// CSAT survey events
DispatchEventCsatSurveySent DispatchEvent = "csat_survey.sent"
DispatchEventCsatResponseReceived DispatchEvent = "csat_survey.response_received"
// Campaign events
DispatchEventCampaignStarted DispatchEvent = "campaign.started"
DispatchEventCampaignCompleted DispatchEvent = "campaign.completed"
// Webhook/hook events
DispatchEventHookTriggered DispatchEvent = "hook.triggered"
DispatchEventWebhookSent DispatchEvent = "webhook.sent"
DispatchEventWebhookFailed DispatchEvent = "webhook.failed"
)
// NewChannelEvent creates a ChannelEvent with the given type and data.
// Timestamp is set to the current Unix milliseconds.
func NewChannelEvent(eventType string, data map[string]interface{}) *channel.ChannelEvent {
if data == nil {
data = make(map[string]interface{})
}
return &channel.ChannelEvent{
Type: channel.EventType(eventType),
Data: data,
Timestamp: time.Now().UnixMilli(),
}
}
// NewDispatchEvent creates a ChannelEvent from a DispatchEvent type.
func NewDispatchEvent(eventType DispatchEvent, data map[string]interface{}) *channel.ChannelEvent {
return NewChannelEvent(string(eventType), data)
}
// EventDataWithConversation creates event data containing a conversation ID.
// This is a common pattern where events need to reference a specific conversation.
func EventDataWithConversation(conversationID uint, accountID uint, inboxID uint) map[string]interface{} {
return map[string]interface{}{
"conversation_id": conversationID,
"account_id": accountID,
"inbox_id": inboxID,
}
}
// EventDataWithAssignment creates event data for an assignment event,
// including the agent who was assigned.
func EventDataWithAssignment(conversationID uint, accountID uint, inboxID uint, assigneeID uint) map[string]interface{} {
data := EventDataWithConversation(conversationID, accountID, inboxID)
data["assignee_id"] = assigneeID
return data
}
// ExtractConversationID extracts a conversation ID from event data.
// Returns 0 and an error if not found or not a valid uint.
func ExtractConversationID(data map[string]interface{}) (uint, error) {
raw, ok := data["conversation_id"]
if !ok {
return 0, fmt.Errorf("conversation_id not found in event data")
}
switch v := raw.(type) {
case uint:
return v, nil
case int:
return uint(v), nil
case float64:
return uint(v), nil
default:
return 0, fmt.Errorf("unexpected type for conversation_id: %T", raw)
}
}
// ExtractAccountID extracts an account ID from event data.
func ExtractAccountID(data map[string]interface{}) (uint, error) {
raw, ok := data["account_id"]
if !ok {
return 0, fmt.Errorf("account_id not found in event data")
}
switch v := raw.(type) {
case uint:
return v, nil
case int:
return uint(v), nil
case float64:
return uint(v), nil
default:
return 0, fmt.Errorf("unexpected type for account_id: %T", raw)
}
}
// ExtractInboxID extracts an inbox ID from event data.
func ExtractInboxID(data map[string]interface{}) (uint, error) {
raw, ok := data["inbox_id"]
if !ok {
return 0, fmt.Errorf("inbox_id not found in event data")
}
switch v := raw.(type) {
case uint:
return v, nil
case int:
return uint(v), nil
case float64:
return uint(v), nil
default:
return 0, fmt.Errorf("unexpected type for inbox_id: %T", raw)
}
}
// ExtractAssigneeID extracts an assignee (agent) ID from event data.
func ExtractAssigneeID(data map[string]interface{}) (uint, error) {
raw, ok := data["assignee_id"]
if !ok {
return 0, fmt.Errorf("assignee_id not found in event data")
}
switch v := raw.(type) {
case uint:
return v, nil
case int:
return uint(v), nil
case float64:
return uint(v), nil
default:
return 0, fmt.Errorf("unexpected type for assignee_id: %T", raw)
}
}
@@ -0,0 +1,87 @@
package dispatch
// ListenerRegistry maps event names to lists of listener names.
// This allows efficient filtering: when an event of type X is dispatched,
// the dispatcher only calls listeners registered for X.
//
// Reference: Chatwoot Wisper event routing pattern
// - In Chatwoot, each listener subscribes to specific event names via Wisper.
// - The registry stores these subscriptions so the dispatcher can efficiently
// route events only to interested listeners.
type ListenerRegistry struct {
// subscriptions maps event name → set of listener names.
// An event name of "*" means the listener subscribes to all events.
subscriptions map[string]map[string]bool // eventName → listenerName → true
}
// NewListenerRegistry creates an empty registry.
func NewListenerRegistry() *ListenerRegistry {
return &ListenerRegistry{
subscriptions: make(map[string]map[string]bool),
}
}
// Subscribe registers a listener for the given event names.
// If eventNames is empty, the listener subscribes to all events (wildcard).
func (lr *ListenerRegistry) Subscribe(listenerName string, eventNames ...string) {
if len(eventNames) == 0 {
eventNames = []string{"*"} // wildcard: receives all events
}
for _, eventName := range eventNames {
if lr.subscriptions[eventName] == nil {
lr.subscriptions[eventName] = make(map[string]bool)
}
lr.subscriptions[eventName][listenerName] = true
}
}
// Unsubscribe removes a listener from all event subscriptions.
func (lr *ListenerRegistry) Unsubscribe(listenerName string) {
for eventName, listeners := range lr.subscriptions {
delete(listeners, listenerName)
if len(listeners) == 0 {
delete(lr.subscriptions, eventName)
}
}
}
// SubscribersForEvent returns all listener names subscribed to the given event.
// It includes both event-specific subscribers and wildcard subscribers.
func (lr *ListenerRegistry) SubscribersForEvent(eventName string) []string {
result := make(map[string]bool)
// Add wildcard subscribers (subscribed to all events)
if wildcards, ok := lr.subscriptions["*"]; ok {
for name := range wildcards {
result[name] = true
}
}
// Add event-specific subscribers
if subs, ok := lr.subscriptions[eventName]; ok {
for name := range subs {
result[name] = true
}
}
names := make([]string, 0, len(result))
for name := range result {
names = append(names, name)
}
return names
}
// AllSubscriptions returns the full subscription map (for debugging/inspection).
func (lr *ListenerRegistry) AllSubscriptions() map[string]map[string]bool {
return lr.subscriptions
}
// HasSubscribers checks if any listener is subscribed to the given event.
func (lr *ListenerRegistry) HasSubscribers(eventName string) bool {
if len(lr.subscriptions["*"]) > 0 {
return true
}
return len(lr.subscriptions[eventName]) > 0
}