261 lines
10 KiB
Go
261 lines
10 KiB
Go
package channel
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// --- Outgoing Pipeline Stage Interface & Context ---
|
|
|
|
// OutgoingPipelineStage is the interface for a single step in the outgoing
|
|
// message processing pipeline. Each stage receives the accumulated
|
|
// OutgoingPipelineContext and returns a modified context or an error.
|
|
// Reference: Chatwoot SendOn*Service pattern — per-channel outbound services
|
|
// inheriting from Base::SendOnChannelService. GoChat unifies into a pipeline.
|
|
type OutgoingPipelineStage interface {
|
|
// Name returns a human-readable stage name for logging.
|
|
Name() string
|
|
|
|
// Process executes the pipeline stage, mutating outCtx as needed.
|
|
Process(ctx context.Context, outCtx *OutgoingPipelineContext) (*OutgoingPipelineContext, error)
|
|
}
|
|
|
|
// OutgoingPipelineContext carries state through the outgoing message pipeline.
|
|
type OutgoingPipelineContext struct {
|
|
// Input fields (set before pipeline starts)
|
|
Inbox *model.Inbox `json:"inbox"`
|
|
Message *model.Message `json:"message"`
|
|
Contact *model.Contact `json:"contact"`
|
|
|
|
// Accumulated fields (populated by pipeline stages)
|
|
ChannelConfig ChannelConfig `json:"channel_config,omitempty"` // resolved per-inbox config
|
|
SendResult *SendResult `json:"send_result,omitempty"` // result from provider send
|
|
Provider ChannelProvider `json:"-"` // resolved channel provider
|
|
}
|
|
|
|
// --- OutgoingMessageProcessor ---
|
|
|
|
// OutgoingMessageProcessor orchestrates the outgoing message pipeline.
|
|
// It holds an ordered list of OutgoingPipelineStage implementations and runs
|
|
// them sequentially, aborting on the first error.
|
|
//
|
|
// Reference: Chatwoot Base::SendOnChannelService + per-channel SendOn*Service
|
|
// - Validates message & channel
|
|
// - Resolves channel configuration
|
|
// - Sends message via external channel API (Telegram Bot API, FB Graph API, etc.)
|
|
// - Updates message source_id with external ID from API response
|
|
// - Fires Wisper events for notifications, webhooks, etc.
|
|
type OutgoingMessageProcessor struct {
|
|
stages []OutgoingPipelineStage
|
|
}
|
|
|
|
// NewOutgoingMessageProcessor creates a processor with the standard pipeline stages.
|
|
func NewOutgoingMessageProcessor(
|
|
messageRepo MessageRepository,
|
|
inboxRepo InboxRepository,
|
|
) *OutgoingMessageProcessor {
|
|
stages := []OutgoingPipelineStage{
|
|
&ValidateOutgoingStage{},
|
|
&ConfigResolutionStage{inboxRepo: inboxRepo},
|
|
&SendMessageStage{},
|
|
&UpdateMessageStage{messageRepo: messageRepo},
|
|
&EventDispatchOutgoingStage{},
|
|
}
|
|
return &OutgoingMessageProcessor{stages: stages}
|
|
}
|
|
|
|
// Process runs all outgoing pipeline stages sequentially.
|
|
// If any stage returns an error, the pipeline aborts and the error is propagated.
|
|
func (p *OutgoingMessageProcessor) Process(ctx context.Context, outCtx *OutgoingPipelineContext) (*OutgoingPipelineContext, error) {
|
|
for _, stage := range p.stages {
|
|
applogger.L().Infof("Outgoing pipeline: executing stage %s", stage.Name())
|
|
result, err := stage.Process(ctx, outCtx)
|
|
if err != nil {
|
|
applogger.L().Errorf("Outgoing pipeline: stage %s failed: %v", stage.Name(), err)
|
|
return nil, fmt.Errorf("stage %s: %w", stage.Name(), err)
|
|
}
|
|
outCtx = result
|
|
}
|
|
applogger.L().Infof("Outgoing pipeline: all stages completed successfully")
|
|
return outCtx, nil
|
|
}
|
|
|
|
// --- Outgoing Pipeline Stages ---
|
|
|
|
// ValidateOutgoingStage validates the outgoing message and inbox for required fields.
|
|
// Ensures that the message has content, a valid inbox, and a registered provider.
|
|
// Reference: Chatwoot SendOnChannelService validates message before sending.
|
|
type ValidateOutgoingStage struct{}
|
|
|
|
func (s *ValidateOutgoingStage) Name() string { return "validate_outgoing" }
|
|
|
|
func (s *ValidateOutgoingStage) Process(ctx context.Context, oc *OutgoingPipelineContext) (*OutgoingPipelineContext, error) {
|
|
message := oc.Message
|
|
if message == nil {
|
|
return nil, fmt.Errorf("outgoing message is nil")
|
|
}
|
|
if message.Content == "" && message.ContentType != "file" && message.ContentType != "image" {
|
|
return nil, fmt.Errorf("outgoing message id=%d has no content", message.ID)
|
|
}
|
|
if message.MessageType != string(model.MessageTypeOutgoing) {
|
|
return nil, fmt.Errorf("message id=%d is not an outgoing message (type=%s)",
|
|
message.ID, message.MessageType)
|
|
}
|
|
|
|
inbox := oc.Inbox
|
|
if inbox == nil {
|
|
return nil, fmt.Errorf("inbox is nil")
|
|
}
|
|
if !inbox.EnableAutoAssignment {
|
|
return nil, fmt.Errorf("inbox %d is disabled", inbox.ID)
|
|
}
|
|
|
|
// Verify the channel provider is registered
|
|
provider, err := Get(ChannelType(inbox.ChannelType))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("no provider registered for channel type %s: %w", inbox.ChannelType, err)
|
|
}
|
|
oc.Provider = provider
|
|
|
|
applogger.L().Infof("ValidateOutgoingStage: message id=%d valid, provider=%s",
|
|
message.ID, provider.Name())
|
|
return oc, nil
|
|
}
|
|
|
|
// ConfigResolutionStage resolves the channel configuration for the inbox.
|
|
// It loads the inbox's ChannelConfig and validates it against the provider's
|
|
// schema, ensuring the channel is properly configured before sending.
|
|
// Reference: Chatwoot channels use per-inbox config stored as JSON in the
|
|
// polymorphic channel model (e.g. Channel::Telegram has bot_token, etc.)
|
|
type ConfigResolutionStage struct {
|
|
inboxRepo InboxRepository
|
|
}
|
|
|
|
func (s *ConfigResolutionStage) Name() string { return "config_resolution" }
|
|
|
|
func (s *ConfigResolutionStage) Process(ctx context.Context, oc *OutgoingPipelineContext) (*OutgoingPipelineContext, error) {
|
|
inbox := oc.Inbox
|
|
provider := oc.Provider
|
|
|
|
// Load full inbox with channel config from repository
|
|
// The inbox passed in may be a partial object; reload to get config
|
|
fullInbox, err := s.inboxRepo.FindByID(inbox.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load inbox id=%d: %w", inbox.ID, err)
|
|
}
|
|
|
|
// Resolve channel config from inbox
|
|
// Reference: Chatwoot stores per-channel config in the polymorphic channel model.
|
|
// In GoChat we store it in the Inbox model itself via a JSON column or separate
|
|
// config table. For now, we pass it via the pipeline context.
|
|
channelConfig := make(ChannelConfig)
|
|
|
|
// If the inbox has additional config stored, merge it here.
|
|
// This is where you'd deserialize the Inbox's config JSON column.
|
|
// For the pipeline abstraction, we just ensure the config is available.
|
|
if fullInbox != nil {
|
|
oc.Inbox = fullInbox
|
|
}
|
|
|
|
// Validate config against provider schema
|
|
if err := provider.ValidateConfig(ctx, channelConfig); err != nil {
|
|
return nil, fmt.Errorf("channel config validation failed for inbox %d: %w", inbox.ID, err)
|
|
}
|
|
|
|
oc.ChannelConfig = channelConfig
|
|
applogger.L().Infof("ConfigResolutionStage: config resolved and validated for inbox id=%d", inbox.ID)
|
|
return oc, nil
|
|
}
|
|
|
|
// SendMessageStage sends the message via the channel provider's SendMessage method.
|
|
// Reference: Chatwoot per-channel SendOn*Service calls the external API
|
|
// (Telegram Bot API sendMessage, FB Graph API messages, WhatsApp Business API, etc.)
|
|
// Each provider handles its own API protocol, error handling, and retries.
|
|
type SendMessageStage struct{}
|
|
|
|
func (s *SendMessageStage) Name() string { return "send_message" }
|
|
|
|
func (s *SendMessageStage) Process(ctx context.Context, oc *OutgoingPipelineContext) (*OutgoingPipelineContext, error) {
|
|
provider := oc.Provider
|
|
inbox := oc.Inbox
|
|
message := oc.Message
|
|
contact := oc.Contact
|
|
|
|
// Call the provider's SendMessage to send via the external channel API
|
|
// Reference: Chatwoot SendOnChannelService.perform → per-channel API call
|
|
result, err := provider.SendMessage(ctx, inbox, message, contact)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("provider %s failed to send message id=%d: %w",
|
|
provider.Name(), message.ID, err)
|
|
}
|
|
|
|
oc.SendResult = result
|
|
applogger.L().Infof("SendMessageStage: message id=%d sent via %s, external_id=%s",
|
|
message.ID, provider.Name(), result.ExternalID)
|
|
return oc, nil
|
|
}
|
|
|
|
// UpdateMessageStage updates the message's SourceID with the external ID
|
|
// returned by the channel provider. This links the GoChat message to the
|
|
// external channel message for future reference (delivery tracking, replies, etc.)
|
|
// Reference: Chatwoot updates message.source_id after successful send.
|
|
type UpdateMessageStage struct {
|
|
messageRepo MessageRepository
|
|
}
|
|
|
|
func (s *UpdateMessageStage) Name() string { return "update_message" }
|
|
|
|
func (s *UpdateMessageStage) Process(ctx context.Context, oc *OutgoingPipelineContext) (*OutgoingPipelineContext, error) {
|
|
message := oc.Message
|
|
sendResult := oc.SendResult
|
|
|
|
if sendResult == nil || sendResult.ExternalID == "" {
|
|
applogger.L().Warnf("UpdateMessageStage: no external_id from send result, skipping update")
|
|
return oc, nil
|
|
}
|
|
|
|
// Update the message's source_id with the external channel message ID
|
|
// Reference: Chatwoot message.update!(source_id: external_id)
|
|
message.SourceID = sendResult.ExternalID
|
|
|
|
updated, err := s.messageRepo.Update(ctx, message)
|
|
if err != nil {
|
|
// Non-critical: the message was already sent successfully,
|
|
// we just couldn't persist the source_id. Log and continue.
|
|
applogger.L().Warnf("UpdateMessageStage: failed to update message source_id: %v", err)
|
|
return oc, nil
|
|
}
|
|
|
|
oc.Message = updated
|
|
applogger.L().Infof("UpdateMessageStage: message id=%d updated with source_id=%s",
|
|
updated.ID, updated.SourceID)
|
|
return oc, nil
|
|
}
|
|
|
|
// EventDispatchOutgoingStage prepares event dispatch data for the outgoing message.
|
|
// The actual event publishing is done by the MessageBroker after the pipeline
|
|
// completes. This stage performs any final state mutations needed before events.
|
|
// Reference: Chatwoot fires Wisper events after outgoing message delivery.
|
|
type EventDispatchOutgoingStage struct{}
|
|
|
|
func (s *EventDispatchOutgoingStage) Name() string { return "event_dispatch_outgoing" }
|
|
|
|
func (s *EventDispatchOutgoingStage) Process(ctx context.Context, oc *OutgoingPipelineContext) (*OutgoingPipelineContext, error) {
|
|
// The MessageBroker handles actual event publishing after the pipeline.
|
|
// This stage is a placeholder for any final pre-dispatch mutations,
|
|
// such as updating conversation last_message_at, or marking the
|
|
// conversation as needing notification delivery.
|
|
|
|
if oc.Message != nil {
|
|
// In a full implementation, we'd load the conversation here
|
|
// to update last_message_at and trigger notification events.
|
|
// For the pipeline abstraction, we just ensure the message
|
|
// context is complete for the broker to publish events.
|
|
applogger.L().Infof("EventDispatchOutgoingStage: outgoing pipeline context ready for event dispatch")
|
|
}
|
|
|
|
return oc, nil
|
|
} |