* H-16: align takeover with channel AI workflow (#2) * feat(conversations): complete manual AI takeover * fix(conversations): align AI takeover flow with channel AI * fix(conversations): close takeover review gaps --------- Co-authored-by: Rogee <rogee@ipao.vip> * feat(shangwutong): sync customer names back to channel (#3) Co-authored-by: Rogee <rogee@ipao.vip> * fix(shangwutong): close contact sync review gaps (#4) Co-authored-by: Rogee <rogee@ipao.vip> * H-28: harden Shangwutong CID sync (#5) * fix(shangwutong): close contact sync review gaps * fix(shangwutong): harden CID sync boundaries --------- Co-authored-by: Rogee <rogee@ipao.vip> * fix(conversations): sync AI takeover exit in realtime (#6) Co-authored-by: Rogee <rogee@ipao.vip> * test(shangwutong): cover CID rename reliability (#7) Co-authored-by: Rogee <rogee@ipao.vip> * H-43: fix WEB Captain takeover E2E flow (#8) * test(shangwutong): cover CID rename reliability * H-43: fix WEB Captain takeover flow * H-48: preserve compatible provider model * H-49: make Captain takeover atomic * H-50: prevent duplicate widget initialization --------- Co-authored-by: Rogee <rogee@ipao.vip> * H-55: make Captain bindings atomic (#9) Co-authored-by: Rogee <rogee@ipao.vip> * H-60: harden Captain migration rollback and concurrency * chore(agent): baseline — uncommitted work from the local directory * H-335: add safe Captain skills and user deactivation * H-338: close auth and Captain review blockers * H-338: close assignment and session races * H-338: close assignment and websocket invalidation gaps * H-338: enforce assignment write invariants --------- Co-authored-by: Rogee <rogee@ipao.vip>
245 lines
8.0 KiB
Go
245 lines
8.0 KiB
Go
package campaign
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/gochat/gochat/internal/channel"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository/conversationassignee"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// CampaignService provides business logic for campaign operations.
|
|
// Reference: Chatwoot CampaignService — CRUD + campaign triggering.
|
|
type CampaignService struct {
|
|
db *gorm.DB
|
|
dispatcher *channel.Dispatcher
|
|
}
|
|
|
|
func NewCampaignService(db *gorm.DB, dispatchers ...*channel.Dispatcher) *CampaignService {
|
|
var dispatcher *channel.Dispatcher
|
|
if len(dispatchers) > 0 {
|
|
dispatcher = dispatchers[0]
|
|
}
|
|
return &CampaignService{db: db, dispatcher: dispatcher}
|
|
}
|
|
|
|
// Create creates a new campaign.
|
|
func (s *CampaignService) Create(ctx context.Context, campaign *Campaign) error {
|
|
if campaign.DisplayID == 0 {
|
|
var next uint
|
|
if err := s.db.WithContext(ctx).Model(&Campaign{}).
|
|
Select("COALESCE(MAX(display_id), 0) + 1").Scan(&next).Error; err != nil {
|
|
return err
|
|
}
|
|
campaign.DisplayID = next
|
|
}
|
|
return s.db.WithContext(ctx).Create(campaign).Error
|
|
}
|
|
|
|
// GetByID retrieves a campaign by ID.
|
|
func (s *CampaignService) GetByID(ctx context.Context, id uint) (*Campaign, error) {
|
|
var c Campaign
|
|
if err := s.db.WithContext(ctx).First(&c, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
// ListByAccount returns campaigns for an account with pagination.
|
|
func (s *CampaignService) ListByAccount(ctx context.Context, accountID uint, offset, limit int) ([]Campaign, int64, error) {
|
|
var campaigns []Campaign
|
|
var count int64
|
|
db := s.db.WithContext(ctx).Model(&Campaign{}).Where("account_id = ?", accountID)
|
|
db.Count(&count)
|
|
if err := db.Offset(offset).Limit(limit).Order("created_at DESC").Find(&campaigns).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return campaigns, count, nil
|
|
}
|
|
|
|
// ListByInbox returns campaigns for a specific inbox.
|
|
func (s *CampaignService) ListByInbox(ctx context.Context, accountID, inboxID uint) ([]Campaign, error) {
|
|
var campaigns []Campaign
|
|
err := s.db.WithContext(ctx).
|
|
Where("account_id = ? AND inbox_id = ?", accountID, inboxID).
|
|
Order("created_at DESC").Find(&campaigns).Error
|
|
return campaigns, err
|
|
}
|
|
|
|
// Update updates campaign fields.
|
|
func (s *CampaignService) Update(ctx context.Context, id uint, updates map[string]interface{}) error {
|
|
return s.db.WithContext(ctx).Model(&Campaign{}).Where("id = ?", id).Updates(updates).Error
|
|
}
|
|
|
|
// Delete soft-deletes a campaign.
|
|
func (s *CampaignService) Delete(ctx context.Context, id uint) error {
|
|
return s.db.WithContext(ctx).Delete(&Campaign{}, id).Error
|
|
}
|
|
|
|
// MarkCompleted transitions a campaign to completed status.
|
|
func (s *CampaignService) MarkCompleted(ctx context.Context, id uint) error {
|
|
return s.Update(ctx, id, map[string]interface{}{
|
|
"campaign_status": CampaignStatusCompleted,
|
|
})
|
|
}
|
|
|
|
// TriggerCampaign executes a campaign by creating conversations for the target audience.
|
|
// Reference: Chatwoot CampaignService.trigger — builds conversation from campaign.
|
|
func (s *CampaignService) TriggerCampaign(ctx context.Context, campaignID uint) error {
|
|
campaign, err := s.GetByID(ctx, campaignID)
|
|
if err != nil {
|
|
return fmt.Errorf("campaign: get campaign: %w", err)
|
|
}
|
|
|
|
if !campaign.Enabled {
|
|
applogger.L().Info("campaign: campaign is disabled, skipping trigger", "campaign_id", campaignID)
|
|
return nil
|
|
}
|
|
|
|
builder := NewCampaignConversationBuilder(s.db, s.dispatcher)
|
|
return builder.Build(ctx, campaign)
|
|
}
|
|
|
|
// CampaignConversationBuilder builds a conversation from a campaign trigger.
|
|
// Reference: Chatwoot CampaignListener — on campaign_triggered event -> builds conversation.
|
|
type CampaignConversationBuilder struct {
|
|
db *gorm.DB
|
|
dispatcher *channel.Dispatcher
|
|
}
|
|
|
|
func NewCampaignConversationBuilder(db *gorm.DB, dispatchers ...*channel.Dispatcher) *CampaignConversationBuilder {
|
|
var dispatcher *channel.Dispatcher
|
|
if len(dispatchers) > 0 {
|
|
dispatcher = dispatchers[0]
|
|
}
|
|
return &CampaignConversationBuilder{db: db, dispatcher: dispatcher}
|
|
}
|
|
|
|
// Build creates a new conversation from the campaign for each contact in the audience.
|
|
// The audience field (JSONB) contains contact IDs or filter criteria.
|
|
func (b *CampaignConversationBuilder) Build(ctx context.Context, campaign *Campaign) error {
|
|
contactIDs, err := campaignAudienceContactIDs(campaign.Audience)
|
|
if err != nil {
|
|
return fmt.Errorf("campaign: parse audience: %w", err)
|
|
}
|
|
inbox := b.loadInbox(ctx, campaign)
|
|
|
|
for _, contactID := range contactIDs {
|
|
conv := &model.Conversation{
|
|
AccountID: campaign.AccountID,
|
|
InboxID: campaign.InboxID,
|
|
ContactID: contactID,
|
|
CampaignID: &campaign.ID,
|
|
Status: "open",
|
|
ChannelType: "campaign",
|
|
}
|
|
|
|
if campaign.SenderID != nil {
|
|
conv.AssigneeID = campaign.SenderID
|
|
}
|
|
|
|
assigneeID := conv.AssigneeID
|
|
conv.AssigneeID = nil
|
|
err := b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Create(conv).Error; err != nil {
|
|
return err
|
|
}
|
|
if assigneeID != nil {
|
|
return conversationassignee.Update(ctx, tx, campaign.AccountID, conv.ID, *assigneeID, nil)
|
|
}
|
|
return nil
|
|
})
|
|
conv.AssigneeID = assigneeID
|
|
if err != nil {
|
|
applogger.L().Error("campaign: failed to create conversation for contact",
|
|
"campaign_id", campaign.ID, "contact_id", contactID, "error", err)
|
|
continue
|
|
}
|
|
b.dispatch(ctx, channel.EventConversationCreated, campaign, inbox, conv, nil)
|
|
b.dispatch(ctx, channel.EventConversationOpened, campaign, inbox, conv, nil)
|
|
|
|
// Create the initial campaign message in the conversation
|
|
msg := &model.Message{
|
|
ConversationID: conv.ID,
|
|
AccountID: campaign.AccountID,
|
|
InboxID: campaign.InboxID,
|
|
Content: campaign.Message,
|
|
ContentType: "template",
|
|
MessageType: "outgoing",
|
|
SenderType: "agent",
|
|
}
|
|
if campaign.SenderID != nil {
|
|
msg.SenderID = campaign.SenderID
|
|
}
|
|
|
|
if err := b.db.WithContext(ctx).Create(msg).Error; err != nil {
|
|
applogger.L().Error("campaign: failed to create campaign message",
|
|
"campaign_id", campaign.ID, "conversation_id", conv.ID, "error", err)
|
|
continue
|
|
}
|
|
b.dispatch(ctx, channel.EventMessageCreated, campaign, inbox, conv, msg)
|
|
b.dispatch(ctx, channel.EventMessageOutgoing, campaign, inbox, conv, msg)
|
|
|
|
applogger.L().Info("campaign: created conversation from campaign",
|
|
"campaign_id", campaign.ID, "conversation_id", conv.ID, "contact_id", contactID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (b *CampaignConversationBuilder) loadInbox(ctx context.Context, c *Campaign) *model.Inbox {
|
|
if c.Inbox.ID != 0 {
|
|
return &c.Inbox
|
|
}
|
|
var inbox model.Inbox
|
|
if err := b.db.WithContext(ctx).Where("account_id = ? AND id = ?", c.AccountID, c.InboxID).First(&inbox).Error; err != nil {
|
|
return nil
|
|
}
|
|
return &inbox
|
|
}
|
|
|
|
func (b *CampaignConversationBuilder) dispatch(ctx context.Context, eventType channel.EventType, campaign *Campaign, inbox *model.Inbox, conversation *model.Conversation, message *model.Message) {
|
|
if b.dispatcher == nil || campaign == nil || conversation == nil {
|
|
return
|
|
}
|
|
channelType := channel.ChannelAPI
|
|
inboxID := campaign.InboxID
|
|
if inbox != nil {
|
|
channelType = channel.ChannelType(inbox.ChannelType)
|
|
inboxID = inbox.ID
|
|
}
|
|
event := channel.NewChannelEvent(eventType, channelType, campaign.AccountID, inboxID)
|
|
event.ConversationID = conversation.ID
|
|
event.ContactID = conversation.ContactID
|
|
event.Data["campaign_id"] = campaign.ID
|
|
event.Data["conversation"] = conversation
|
|
if inbox != nil {
|
|
event.Data["inbox"] = inbox
|
|
}
|
|
if message != nil {
|
|
event.Data["message"] = message
|
|
}
|
|
if err := b.dispatcher.DispatchAsync(ctx, event); err != nil {
|
|
applogger.L().Warn("campaign: failed to dispatch campaign event", "campaign_id", campaign.ID, "event", eventType, "error", err)
|
|
}
|
|
}
|
|
|
|
func campaignAudienceContactIDs(raw string) ([]uint, error) {
|
|
var audienceData struct {
|
|
ContactIDs []uint `json:"contact_ids"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &audienceData); err == nil {
|
|
return audienceData.ContactIDs, nil
|
|
}
|
|
|
|
var frontendRules []map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &frontendRules); err != nil {
|
|
return nil, err
|
|
}
|
|
return []uint{}, nil
|
|
}
|