449 lines
15 KiB
Go
449 lines
15 KiB
Go
package automation
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/worker"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// 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
|
|
worker *worker.WorkerPool
|
|
}
|
|
|
|
const TaskTypeMacroExecution = "automation:macro_execution"
|
|
|
|
// NewMacroService creates a new MacroService.
|
|
func NewMacroService(db DBProvider) *MacroService {
|
|
return &MacroService{db: db}
|
|
}
|
|
|
|
func NewMacroServiceWithWorker(db DBProvider, wp *worker.WorkerPool) *MacroService {
|
|
s := NewMacroService(db)
|
|
s.SetWorkerPool(wp)
|
|
return s
|
|
}
|
|
|
|
func (s *MacroService) SetWorkerPool(wp *worker.WorkerPool) {
|
|
s.worker = wp
|
|
RegisterActionDeliveryJobs(wp, s.db)
|
|
RegisterMacroExecutionJobs(wp, s.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).Preload("CreatedBy").Preload("UpdatedBy").First(¯o, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
_ = s.hydrateMacroFiles(ctx, ¯o)
|
|
return ¯o, nil
|
|
}
|
|
|
|
// GetByIDForAccount retrieves a macro scoped to an account.
|
|
func (s *MacroService) GetByIDForAccount(ctx context.Context, accountID, id uint) (*Macro, error) {
|
|
var macro Macro
|
|
if err := s.db.DB().WithContext(ctx).
|
|
Preload("CreatedBy").Preload("UpdatedBy").
|
|
Where("account_id = ?", accountID).
|
|
First(¯o, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
_ = s.hydrateMacroFiles(ctx, ¯o)
|
|
return ¯o, 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.Preload("CreatedBy").Preload("UpdatedBy").Order("id ASC").Find(¯os).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range macros {
|
|
_ = s.hydrateMacroFiles(ctx, ¯os[i])
|
|
}
|
|
return macros, nil
|
|
}
|
|
|
|
// Create creates a new macro.
|
|
func (s *MacroService) Create(ctx context.Context, macro *Macro) error {
|
|
if strings.TrimSpace(macro.Name) == "" {
|
|
return fmt.Errorf("name is required")
|
|
}
|
|
if macro.Actions == nil {
|
|
macro.Actions = Actions{}
|
|
}
|
|
if err := s.normalizeMacroAttachmentActions(ctx, macro.AccountID, ¯o.Actions); err != nil {
|
|
return err
|
|
}
|
|
macro.Active = true
|
|
// 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.
|
|
if err := s.db.DB().WithContext(ctx).Select(
|
|
"AccountID", "Name", "Actions", "Visibility", "Active",
|
|
"CreatedByID", "UpdatedByID",
|
|
).Create(macro).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := s.db.DB().WithContext(ctx).Preload("CreatedBy").Preload("UpdatedBy").First(macro, macro.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
return s.hydrateMacroFiles(ctx, macro)
|
|
}
|
|
|
|
// Update updates an existing macro.
|
|
func (s *MacroService) Update(ctx context.Context, macro *Macro) error {
|
|
return s.db.DB().WithContext(ctx).Save(macro).Error
|
|
}
|
|
|
|
// UpdateForAccount updates an existing macro within account scope while preserving ownership.
|
|
func (s *MacroService) UpdateForAccount(ctx context.Context, accountID uint, macro *Macro) (*Macro, error) {
|
|
existing, err := s.GetByIDForAccount(ctx, accountID, macro.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
existing.Name = macro.Name
|
|
if macro.Actions != nil {
|
|
if err := s.normalizeMacroAttachmentActions(ctx, accountID, ¯o.Actions); err != nil {
|
|
return nil, err
|
|
}
|
|
existing.Actions = macro.Actions
|
|
}
|
|
existing.Visibility = macro.Visibility
|
|
existing.UpdatedByID = macro.UpdatedByID
|
|
if err := s.Update(ctx, existing); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.GetByIDForAccount(ctx, accountID, existing.ID)
|
|
}
|
|
|
|
func (s *MacroService) normalizeMacroAttachmentActions(ctx context.Context, accountID uint, actions *Actions) error {
|
|
if actions == nil {
|
|
return nil
|
|
}
|
|
for i := range *actions {
|
|
action := &(*actions)[i]
|
|
if action.ActionName != "send_attachment" {
|
|
continue
|
|
}
|
|
if action.ActionParams == nil {
|
|
return fmt.Errorf("invalid attachment")
|
|
}
|
|
blobID := firstMacroBlobID(action.ActionParams)
|
|
upload, err := s.findMacroUpload(ctx, accountID, blobID)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid attachment")
|
|
}
|
|
action.ActionParams["blob_id"] = upload.ID
|
|
delete(action.ActionParams, "attachment_url")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func firstMacroBlobID(params map[string]interface{}) interface{} {
|
|
if value := params["blob_id"]; value != nil {
|
|
return value
|
|
}
|
|
return params["attachment_url"]
|
|
}
|
|
|
|
func (s *MacroService) findMacroUpload(ctx context.Context, accountID uint, value interface{}) (*model.DirectUpload, error) {
|
|
switch v := value.(type) {
|
|
case string:
|
|
if parsed, err := strconv.ParseUint(v, 10, 64); err == nil {
|
|
return s.findMacroUploadByID(ctx, accountID, uint(parsed))
|
|
}
|
|
var upload model.DirectUpload
|
|
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND upload_uuid = ?", accountID, v).First(&upload).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &upload, nil
|
|
case float64:
|
|
return s.findMacroUploadByID(ctx, accountID, uint(v))
|
|
case int:
|
|
return s.findMacroUploadByID(ctx, accountID, uint(v))
|
|
case uint:
|
|
return s.findMacroUploadByID(ctx, accountID, v)
|
|
case json.Number:
|
|
parsed, err := strconv.ParseUint(string(v), 10, 64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.findMacroUploadByID(ctx, accountID, uint(parsed))
|
|
default:
|
|
return nil, gorm.ErrRecordNotFound
|
|
}
|
|
}
|
|
|
|
func (s *MacroService) findMacroUploadByID(ctx context.Context, accountID, id uint) (*model.DirectUpload, error) {
|
|
var upload model.DirectUpload
|
|
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).First(&upload).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &upload, nil
|
|
}
|
|
|
|
func (s *MacroService) hydrateMacroFiles(ctx context.Context, macro *Macro) error {
|
|
ids := macroAttachmentBlobIDs(macro.Actions)
|
|
if len(ids) == 0 {
|
|
macro.Files = nil
|
|
return nil
|
|
}
|
|
var uploads []model.DirectUpload
|
|
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id IN ?", macro.AccountID, ids).Order("id ASC").Find(&uploads).Error; err != nil {
|
|
return err
|
|
}
|
|
files := make([]MacroFile, 0, len(uploads))
|
|
for _, upload := range uploads {
|
|
files = append(files, MacroFile{ID: upload.ID, MacroID: macro.ID, FileType: upload.MimeType, AccountID: upload.AccountID, FileURL: upload.FileURL, BlobID: upload.ID, Filename: upload.OriginalName})
|
|
}
|
|
macro.Files = files
|
|
return nil
|
|
}
|
|
|
|
func macroAttachmentBlobIDs(actions Actions) []uint {
|
|
seen := map[uint]bool{}
|
|
ids := []uint{}
|
|
for _, action := range actions {
|
|
if action.ActionName != "send_attachment" {
|
|
continue
|
|
}
|
|
id := macroBlobIDAsUint(action.ActionParams["blob_id"])
|
|
if id == 0 || seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
ids = append(ids, id)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func macroBlobIDAsUint(value interface{}) uint {
|
|
switch v := value.(type) {
|
|
case uint:
|
|
return v
|
|
case int:
|
|
return uint(v)
|
|
case float64:
|
|
return uint(v)
|
|
case string:
|
|
parsed, _ := strconv.ParseUint(v, 10, 64)
|
|
return uint(parsed)
|
|
case json.Number:
|
|
parsed, _ := strconv.ParseUint(string(v), 10, 64)
|
|
return uint(parsed)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// DeleteForAccount deletes a macro within account scope.
|
|
func (s *MacroService) DeleteForAccount(ctx context.Context, accountID, id uint) error {
|
|
macro, err := s.GetByIDForAccount(ctx, accountID, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.db.DB().WithContext(ctx).Delete(macro).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 := NewActionServiceWithWorker(s.db, s.worker)
|
|
|
|
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)
|
|
}
|
|
|
|
// ExecuteForDisplayIDs runs a macro against account-scoped conversation display IDs.
|
|
// Reference: Chatwoot MacrosExecutionJob resolves account.conversations by display_id.
|
|
func (s *MacroService) ExecuteForDisplayIDs(ctx context.Context, accountID uint, macroID uint, displayIDs []uint, userID uint) error {
|
|
if s.worker != nil {
|
|
return s.enqueueExecuteForDisplayIDs(ctx, accountID, macroID, displayIDs, userID)
|
|
}
|
|
return s.executeForDisplayIDsNow(ctx, accountID, macroID, displayIDs, userID)
|
|
}
|
|
|
|
func (s *MacroService) enqueueExecuteForDisplayIDs(ctx context.Context, accountID uint, macroID uint, displayIDs []uint, userID uint) error {
|
|
if len(displayIDs) == 0 {
|
|
return nil
|
|
}
|
|
_, err := s.GetByIDForAccount(ctx, accountID, macroID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.worker.Enqueue(ctx, TaskTypeMacroExecution, macroExecutionJob{
|
|
AccountID: accountID,
|
|
MacroID: macroID,
|
|
ConversationIDs: displayIDs,
|
|
UserID: userID,
|
|
}, worker.WithQueue("medium"), worker.WithMaxAttempts(3))
|
|
return err
|
|
}
|
|
|
|
func (s *MacroService) executeForDisplayIDsNow(ctx context.Context, accountID uint, macroID uint, displayIDs []uint, userID uint) error {
|
|
macro, err := s.GetByIDForAccount(ctx, accountID, macroID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(displayIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var conversations []model.Conversation
|
|
if err := s.db.DB().WithContext(ctx).
|
|
Where("account_id = ? AND display_id IN ?", accountID, displayIDs).
|
|
Find(&conversations).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(conversations) == 0 {
|
|
return nil
|
|
}
|
|
|
|
actionSvc := NewActionServiceWithWorker(s.db, s.worker)
|
|
for _, conversation := range conversations {
|
|
applogger.L().Infof("executing macro %d (%s) on conversation %d by user %d", macro.ID, macro.Name, conversation.ID, userID)
|
|
for _, action := range macro.Actions {
|
|
params := map[string]interface{}{}
|
|
for key, value := range action.ActionParams {
|
|
params[key] = value
|
|
}
|
|
params["_source_user_id"] = userID
|
|
resolvedAction := Action{ActionName: action.ActionName, ActionParams: params}
|
|
if err := actionSvc.Execute(ctx, accountID, conversation.ID, resolvedAction, ActionSourceMacro, userID); err != nil {
|
|
applogger.L().Errorf("macro action %s failed for macro %d on conversation %d: %v", action.ActionName, macroID, conversation.ID, err)
|
|
}
|
|
}
|
|
if err := s.recordExecution(ctx, macroID, conversation.ID, userID); err != nil && err != gorm.ErrRecordNotFound {
|
|
applogger.L().Warnf("failed to record macro %d execution for conversation %d: %v", macroID, conversation.ID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CanAccess matches Chatwoot MacroPolicy for show/update/destroy/execute.
|
|
func (s *MacroService) CanAccess(macro *Macro, userID uint, role string, action string) bool {
|
|
if macro == nil {
|
|
return false
|
|
}
|
|
if action == "show" || action == "execute" {
|
|
return macro.Visibility == MacroVisibilityGlobal || macro.CreatedByID == userID
|
|
}
|
|
if action == "update" || action == "destroy" {
|
|
if macro.Visibility == MacroVisibilityGlobal {
|
|
return role == "administrator" || role == "super_admin"
|
|
}
|
|
return macro.CreatedByID == userID
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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" }
|