mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
103 lines
3.6 KiB
Go
103 lines
3.6 KiB
Go
package repository
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// validateActionConfig checks a non-email (action/wait) node's typed config
|
|
// before it is persisted. This is a shape gate only; the kind↔action pairing and
|
|
// cross-step routing targets are handled elsewhere (the canvas sets kind+action
|
|
// together, and dangling routes resolve to "stop" at schedule time).
|
|
func validateActionConfig(a *models.ActionConfig) *errx.Error {
|
|
if a == nil {
|
|
return nil
|
|
}
|
|
switch a.Type {
|
|
case "wait", "add_tag", "remove_tag", "label_email", "unsubscribe",
|
|
"notify", "create_task", "create_deal", "move_deal_stage",
|
|
"run_automation", "fire_event", "end":
|
|
// Type must be known. Sub-config (wait minutes, tag, event name, HTTP
|
|
// request) is filled in the editor; an unconfigured node is a harmless
|
|
// no-op at send time, so we don't block creating a draft node here. Keep
|
|
// this list in sync with models.ActionConfig.Type and the task executor's
|
|
// switch (internal/tasks/campaign_task.go, advanced/reply_actions.go).
|
|
return nil
|
|
case "switch":
|
|
// A draft switch (no cases / no decider yet) is a no-op like other
|
|
// unconfigured nodes; bound the surface of what IS set. Cases become
|
|
// draggable dots on the node and route via ai_label branch conditions,
|
|
// so their names share the branch-label bound.
|
|
switch a.SwitchOn {
|
|
case "", "ai", "value":
|
|
default:
|
|
return errx.ErrSequenceAction
|
|
}
|
|
if len(a.AIInstruction) > maxAIStepInstruction || len(a.SwitchValue) > maxSwitchValue {
|
|
return errx.ErrSequenceAction
|
|
}
|
|
if len(a.SwitchCases) > maxSwitchCases {
|
|
return errx.ErrSequenceAction
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, c := range a.SwitchCases {
|
|
name := strings.ToLower(strings.TrimSpace(c))
|
|
if name == "" || len(c) > maxAIStepName || seen[name] {
|
|
return errx.ErrSequenceAction
|
|
}
|
|
seen[name] = true
|
|
// A "/pattern/" case is a regex in value mode: reject patterns that
|
|
// don't compile so a broken case never sits silently unmatched.
|
|
if len(name) >= 3 && strings.HasPrefix(name, "/") && strings.HasSuffix(name, "/") {
|
|
if _, err := regexp.Compile("(?i)" + name[1:len(name)-1]); err != nil {
|
|
return errx.ErrSequenceAction
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
case "ai_step":
|
|
// AI agent step: an instruction plus a guarded allowlist of reversible
|
|
// actions the model may call. A draft (no instruction / no actions) is a
|
|
// harmless no-op like other unconfigured nodes, so bound only what IS set.
|
|
if len(a.AIInstruction) > maxAIStepInstruction {
|
|
return errx.ErrSequenceAction
|
|
}
|
|
if len(a.AIAllowedActions) > maxAIAllowedActions {
|
|
return errx.ErrSequenceAction
|
|
}
|
|
for _, act := range a.AIAllowedActions {
|
|
if !models.IsReversibleCampaignAction(strings.TrimSpace(act)) {
|
|
return errx.ErrSequenceAction
|
|
}
|
|
}
|
|
if len(a.AIAddTags) > maxAITagPool || len(a.AIRemoveTags) > maxAITagPool || len(a.AILabels) > maxAITagPool {
|
|
return errx.ErrSequenceAction
|
|
}
|
|
pools := append([]models.AITagRef{}, a.AIAddTags...)
|
|
pools = append(pools, a.AIRemoveTags...)
|
|
pools = append(pools, a.AILabels...)
|
|
for _, r := range pools {
|
|
if strings.TrimSpace(r.ID) == "" || strings.TrimSpace(r.Name) == "" || len(r.Name) > maxAIStepName {
|
|
return errx.ErrSequenceAction
|
|
}
|
|
}
|
|
return nil
|
|
default:
|
|
return errx.ErrSequenceAction
|
|
}
|
|
}
|
|
|
|
// Switch step bounds. Case names live on the node config AND on the ai_label
|
|
// branch conditions (branch_conditions.go), so they share maxAIStepName.
|
|
const (
|
|
maxAIStepInstruction = 4000
|
|
maxAIStepName = 80
|
|
maxSwitchValue = 500
|
|
maxSwitchCases = 20
|
|
maxAIAllowedActions = 12
|
|
maxAITagPool = 40
|
|
)
|