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

161 lines
5.8 KiB
Go

package automation
import (
"context"
"fmt"
applogger "github.com/gochat/gochat/pkg/logger"
)
// MacroService provides CRUD + execution for macros.
// Reference: Chatwoot Macros::ExecutionService — same action handler pattern as AutomationRule,
// but user-originated (supports 'self' assign). stamps user info.
type MacroService struct {
db DBProvider
}
// NewMacroService creates a new MacroService.
func NewMacroService(db DBProvider) *MacroService {
return &MacroService{db: db}
}
// GetByID retrieves a macro by ID.
func (s *MacroService) GetByID(ctx context.Context, id uint) (*Macro, error) {
var macro Macro
if err := s.db.DB().WithContext(ctx).First(&macro, id).Error; err != nil {
return nil, err
}
return &macro, nil
}
// ListByAccount retrieves all macros for an account, respecting visibility.
// Personal macros are only visible to their creator; global macros are visible to all.
func (s *MacroService) ListByAccount(ctx context.Context, accountID uint, userID uint) ([]Macro, error) {
var macros []Macro
query := s.db.DB().WithContext(ctx).
Where("account_id = ?", accountID)
// Filter by visibility: show global macros + personal macros owned by this user
query = query.Where("visibility = ? OR (visibility = ? AND created_by_id = ?)",
MacroVisibilityGlobal, MacroVisibilityPersonal, userID)
if err := query.Order("name ASC").Find(&macros).Error; err != nil {
return nil, err
}
return macros, nil
}
// Create creates a new macro.
func (s *MacroService) Create(ctx context.Context, macro *Macro) error {
// Use Select to force all fields including zero-value bool Active=false.
// Without Select, GORM skips zero-value fields and uses column defaults.
// Callers should explicitly set Active=true when creating new macros.
return s.db.DB().WithContext(ctx).Select(
"AccountID", "Name", "Actions", "Visibility", "Active",
"CreatedByID", "UpdatedByID",
).Create(macro).Error
}
// Update updates an existing macro.
func (s *MacroService) Update(ctx context.Context, macro *Macro) error {
return s.db.DB().WithContext(ctx).Save(macro).Error
}
// Delete deletes a macro by ID.
func (s *MacroService) Delete(ctx context.Context, id uint) error {
return s.db.DB().WithContext(ctx).Delete(&Macro{}, id).Error
}
// Clone creates a copy of a macro with "(copy)" appended to the name.
// Reference: Chatwoot Macro clone — duplicates actions, resets ID, stamps creator.
func (s *MacroService) Clone(ctx context.Context, id uint) (*Macro, error) {
original, err := s.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to load macro %d for clone: %w", id, err)
}
cloned := &Macro{
AccountID: original.AccountID,
Name: original.Name + " (copy)",
Actions: original.Actions,
Visibility: original.Visibility,
Active: original.Active,
CreatedByID: original.CreatedByID,
UpdatedByID: original.UpdatedByID,
}
if err := s.Create(ctx, cloned); err != nil {
return nil, fmt.Errorf("failed to create cloned macro: %w", err)
}
return cloned, nil
}
// ToggleActive flips the active state of a macro.
func (s *MacroService) ToggleActive(ctx context.Context, id uint, active bool) error {
return s.db.DB().WithContext(ctx).
Model(&Macro{}).
Where("id = ?", id).
Update("active", active).Error
}
// Execute runs a macro's actions on a conversation.
// Reference: Chatwoot Macros::ExecutionService stamps user info (executed_by)
// and supports 'self' assignment (assign to the user running the macro).
// Template variables in action params (e.g. {{contact.name}}) are resolved
// by ActionService before execution — the macro layer injects _source_user_id
// for 'self' assignment support and delegates execution to ActionService.
func (s *MacroService) Execute(ctx context.Context, accountID uint, conversationID uint, macroID uint, userID uint) error {
macro, err := s.GetByID(ctx, macroID)
if err != nil {
return fmt.Errorf("failed to load macro %d: %w", macroID, err)
}
applogger.L().Infof("executing macro %d (%s) on conversation %d by user %d", macro.ID, macro.Name, conversationID, userID)
actionSvc := NewActionService(s.db)
for _, action := range macro.Actions {
// Inject _source_user_id for "self" assignment support
params := action.ActionParams
if params == nil {
params = map[string]interface{}{}
}
params["_source_user_id"] = userID
resolvedAction := Action{
ActionName: action.ActionName,
ActionParams: params,
}
// ActionService.Execute resolves template variables internally
// ({{contact.name}}, {{conversation.status}}, etc.) before running the action.
if err := actionSvc.Execute(ctx, accountID, conversationID, resolvedAction, ActionSourceMacro, userID); err != nil {
applogger.L().Errorf("macro action %s failed for macro %d on conversation %d: %v", action.ActionName, macroID, conversationID, err)
// Continue executing remaining actions (Chatwoot pattern)
}
}
// Stamp execution record for audit trail
// Reference: Chatwoot stamps user info (executed_by_id)
return s.recordExecution(ctx, macroID, conversationID, userID)
}
// recordExecution logs that a macro was executed on a conversation by a user.
func (s *MacroService) recordExecution(ctx context.Context, macroID, conversationID, userID uint) error {
record := &MacroExecution{
MacroID: macroID,
ConversationID: conversationID,
ExecutedByID: userID,
}
return s.db.DB().WithContext(ctx).Create(record).Error
}
// MacroExecution records a macro execution event for audit trail.
type MacroExecution struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
MacroID uint `gorm:"index;not null" json:"macro_id"`
ConversationID uint `gorm:"index;not null" json:"conversation_id"`
ExecutedByID uint `gorm:"index;not null" json:"executed_by_id"`
}
func (MacroExecution) TableName() string { return "macro_executions" }