Files
warmbly/internal/app/integration/native_actions.go
T

394 lines
15 KiB
Go

package integration
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
// validateNativeActionConfig checks a native action node's config at write time
// (defense in depth — the dashboard validates too, but API callers may not).
func validateNativeActionConfig(action models.IntegrationAction, raw json.RawMessage) error {
cfg := parseNativeConfig(raw)
switch action {
case models.IntegrationActionAddTag, models.IntegrationActionRemoveTag:
if strings.TrimSpace(cfg.CategoryID) == "" {
return fmt.Errorf("a tag action needs a tag")
}
case models.IntegrationActionCreateDeal, models.IntegrationActionMoveDealStage:
if strings.TrimSpace(cfg.DealPipelineID) == "" || strings.TrimSpace(cfg.DealStageID) == "" {
return fmt.Errorf("a deal action needs a pipeline and stage")
}
case models.IntegrationActionRunAutomation:
if strings.TrimSpace(cfg.AutomationID) == "" {
return fmt.Errorf("a run-automation action needs a target automation")
}
case models.IntegrationActionLabelEmail:
if len(parseUUIDList(cfg.LabelIDs)) == 0 {
return fmt.Errorf("a label action needs at least one label")
}
case models.IntegrationActionSetVariables:
hasOne := false
for _, v := range cfg.SetVars {
if strings.TrimSpace(v.Key) != "" {
hasOne = true
break
}
}
if !hasOne {
return fmt.Errorf("set variables needs at least one named variable")
}
case models.IntegrationActionFireEvent:
if strings.TrimSpace(cfg.EventName) == "" {
return fmt.Errorf("a fire-event action needs an event name")
}
case models.IntegrationActionAIStep:
return validateAIStepConfig(raw)
case models.IntegrationActionAISwitch:
return validateAISwitchConfig(raw)
}
return nil
}
// validateAISwitchConfig validates the AI switch router by decider mode
// (mirrors the campaign switch): "value" mode needs a value template and at
// least two cases (no model call, so no instruction); "ai" mode needs an
// instruction and at least two cases.
func validateAISwitchConfig(raw json.RawMessage) error {
ai := parseAIConfig(raw)
if len(nonEmptyStrings(ai.Cases)) < 2 {
return fmt.Errorf("an AI switch needs at least two cases")
}
if strings.TrimSpace(ai.SwitchOn) == "value" {
if strings.TrimSpace(ai.SwitchValue) == "" {
return fmt.Errorf("a value switch needs a value to match")
}
return nil
}
if strings.TrimSpace(ai.Instruction) == "" {
return fmt.Errorf("an AI switch needs an instruction")
}
return nil
}
// isAllowlistedAIAction is the closed set of REVERSIBLE native actions an
// agent-mode AI step may call as tools. Defined as its own switch (NOT derived
// from IsNativeAction) so it can never drift to include run_automation,
// fire_event, a connection-backed action, or any future send/reply action.
func isAllowlistedAIAction(a models.IntegrationAction) bool {
switch a {
case models.IntegrationActionAddTag,
models.IntegrationActionRemoveTag,
models.IntegrationActionCreateTask,
models.IntegrationActionCreateDeal,
models.IntegrationActionMoveDealStage,
models.IntegrationActionLabelEmail,
models.IntegrationActionSetVariables,
models.IntegrationActionUnsubscribe:
return true
default:
return false
}
}
// validateAIStepConfig validates the unified AI step by mode. Routing lives in
// the AI switch node, so decide is not a step mode. Agent mode only checks the
// guarded allowlist: every entry must pass isAllowlistedAIAction, and the agent
// supplies each action's target (which tag/task/deal) as a tool argument at run
// time, so no pinned per-action config is required here.
func validateAIStepConfig(raw json.RawMessage) error {
ai := parseAIConfig(raw)
if strings.TrimSpace(ai.Instruction) == "" {
return fmt.Errorf("an AI step needs an instruction")
}
switch strings.TrimSpace(ai.Mode) {
case "classify":
if len(nonEmptyStrings(ai.Labels)) < 2 {
return fmt.Errorf("classify mode needs at least two labels")
}
case "extract":
if len(nonEmptyStrings(ai.OutputKeys)) == 0 {
return fmt.Errorf("extract mode needs at least one output key")
}
case "agent":
enabled := nonEmptyStrings(ai.Allowlist)
if len(enabled) == 0 {
return fmt.Errorf("agent mode needs at least one allowed action")
}
for _, raw2 := range enabled {
id := models.IntegrationAction(strings.TrimSpace(raw2))
if !isAllowlistedAIAction(id) {
return fmt.Errorf("%q is not an allowed agent action", raw2)
}
}
case "generate", "":
// generate needs only the instruction (already checked).
default:
return fmt.Errorf("unknown AI step mode %q", ai.Mode)
}
return nil
}
// parseUUIDList parses a slice of string ids into uuids, dropping any that don't
// parse. Used by the label_email action's category list.
func parseUUIDList(ids []string) []uuid.UUID {
out := make([]uuid.UUID, 0, len(ids))
for _, s := range ids {
if id, err := uuid.Parse(strings.TrimSpace(s)); err == nil {
out = append(out, id)
}
}
return out
}
// NativeActions runs Warmbly-internal CRM/contact mutations for automation
// action nodes (no external connection). It's a consumer-side interface so the
// integration package needs no import of advanced/tasks/repository — a thin
// adapter in cmd/backend satisfies it (converting *errx.Error -> error and
// resolving the contact + org owner). Mirrors the AutomationRunner pattern.
type NativeActions interface {
// ResolveContact finds the contact the action operates on, by id then email.
ResolveContact(ctx context.Context, orgID uuid.UUID, contactID, email string) (*models.Contact, error)
// OrgOwner returns the organization owner's user id (the actor for created
// deals/tasks, which require a creator).
OrgOwner(ctx context.Context, orgID uuid.UUID) (uuid.UUID, error)
AddTag(ctx context.Context, orgID, actorID, contactID, categoryID uuid.UUID) error
RemoveTag(ctx context.Context, orgID, actorID, contactID, categoryID uuid.UUID) error
CreateTask(ctx context.Context, orgID, createdBy uuid.UUID, data *models.CreateCRMTask) error
CreateDeal(ctx context.Context, orgID, createdBy uuid.UUID, data *models.CreateDeal) error
MoveDealStage(ctx context.Context, orgID, contactID, pipelineID, stageID uuid.UUID) error
Unsubscribe(ctx context.Context, campaignID, contactID uuid.UUID) error
// LabelThread additively applies unibox conversation labels to a thread, on
// behalf of the mailbox-owner userID (categories are per user). Backs the
// "label_email" action; userID + threadID come from the reply event data.
LabelThread(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error
// ListCategories / CreateCategory / ListPipelines back the AI agent step's
// argument-based tools: the model picks a tag/label/pipeline by name and the
// executor resolves it live (empty pool = any of the owner's tags). Keyed by
// the org OWNER (categories are per user); pipelines are org-scoped with
// stages hydrated in position order. Mirrors the campaign agent tools.
ListCategories(ctx context.Context, ownerID uuid.UUID) ([]models.MiniCategory, error)
CreateCategory(ctx context.Context, ownerID uuid.UUID, title, color string) (models.MiniCategory, error)
ListPipelines(ctx context.Context, orgID uuid.UUID) ([]models.Pipeline, error)
}
// nativeActionConfig is the per-node config for native action nodes (mirrors the
// relevant subset of the campaign ActionConfig keys, stored in the node config).
type nativeActionConfig struct {
CategoryID string `json:"category_id"`
DealPipelineID string `json:"deal_pipeline_id"`
DealStageID string `json:"deal_stage_id"`
DealName string `json:"deal_name"`
DealValue *float64 `json:"deal_value"`
DealCurrency string `json:"deal_currency"`
TaskTitle string `json:"task_title"`
TaskType string `json:"task_type"`
TaskPriority string `json:"task_priority"`
TaskDueOffsetDays *int `json:"task_due_offset_days"`
TaskAssignedTo string `json:"task_assigned_to"`
TaskAssignedTeamID string `json:"task_assigned_team_id"`
// run_automation: the automation to launch.
AutomationID string `json:"automation_id"`
// label_email: the unibox conversation labels to apply (category-registry ids).
LabelIDs []string `json:"label_ids"`
// set_variables: named values computed from templates and written back into
// the event data for later nodes to reuse.
SetVars []setVar `json:"set_vars"`
// fire_event: a developer-defined custom event published to the realtime
// gateway. EventName + each field value are Go-templated against the event data.
EventName string `json:"event_name"`
EventFields []setVar `json:"event_fields"`
}
type setVar struct {
Key string `json:"key"`
Value string `json:"value"`
}
func parseNativeConfig(raw json.RawMessage) nativeActionConfig {
var c nativeActionConfig
if len(raw) > 0 {
_ = json.Unmarshal(raw, &c)
}
return c
}
// execNativeAction resolves the contact from the event data and runs the native
// CRM/contact mutation. Returns an error (recorded in run history); never panics.
func (s *service) execNativeAction(ctx context.Context, a models.Automation, n models.AutomationNode, data map[string]any) error {
if s.native == nil {
return fmt.Errorf("native actions are not available")
}
cfg := parseNativeConfig(n.Config)
// run_automation launches another automation against the same event data; it
// does not need a resolved contact, so handle it before contact resolution.
// The chain-depth guard in RunAutomationByID bounds recursion/compute.
if n.Action == models.IntegrationActionRunAutomation {
targetID, perr := uuid.Parse(strings.TrimSpace(cfg.AutomationID))
if perr != nil {
return fmt.Errorf("run-automation needs a target automation")
}
if targetID == a.ID {
return fmt.Errorf("an automation cannot run itself")
}
return s.RunAutomationByID(ctx, a.OrganizationID, targetID, data)
}
// set_variables computes named values from templates and writes them back
// into the event data for later nodes. No contact, no external call.
if n.Action == models.IntegrationActionSetVariables {
for _, v := range cfg.SetVars {
key := strings.TrimSpace(v.Key)
if key == "" {
continue
}
data[key] = renderTemplate(v.Value, data)
}
return nil
}
// fire_event publishes a developer-defined custom event to the realtime
// gateway (org-scoped). Subscribers receive it over the websocket with no
// public URL. Name + each field value are templated against the event data.
if n.Action == models.IntegrationActionFireEvent {
name := strings.TrimSpace(renderTemplate(cfg.EventName, data))
if name == "" {
return fmt.Errorf("fire-event needs an event name")
}
payload := make(map[string]string, len(cfg.EventFields))
for _, f := range cfg.EventFields {
key := strings.TrimSpace(f.Key)
if key == "" {
continue
}
payload[key] = renderTemplate(f.Value, data)
}
if s.publisher != nil {
s.publisher.PublishCustomEvent(ctx, a.OrganizationID, uuid.Nil, name, payload, "automation", a.ID.String())
}
return nil
}
// label_email tags the conversation the event belongs to; it needs the
// thread + mailbox owner (carried by reply triggers), not a resolved contact.
if n.Action == models.IntegrationActionLabelEmail {
threadID := stringFromMap(data, "thread_id")
ownerID, perr := uuid.Parse(stringFromMap(data, "_user_id"))
if threadID == "" || perr != nil {
return fmt.Errorf("label-email needs a reply thread (use it on a reply trigger)")
}
catIDs := parseUUIDList(cfg.LabelIDs)
if len(catIDs) == 0 {
return fmt.Errorf("a label action needs at least one label")
}
return s.native.LabelThread(ctx, ownerID, threadID, catIDs)
}
contactID := stringFromMap(data, "contact_id")
email := stringFromMap(data, "contact_email", "invitee_email", "email")
c, err := s.native.ResolveContact(ctx, a.OrganizationID, contactID, email)
if err != nil || c == nil {
return fmt.Errorf("no contact matched the event (need contact_id or contact_email)")
}
switch n.Action {
case models.IntegrationActionUnsubscribe:
campID, perr := uuid.Parse(stringFromMap(data, "campaign_id"))
if perr != nil {
return fmt.Errorf("unsubscribe needs a campaign_id in the event data")
}
return s.native.Unsubscribe(ctx, campID, c.ID)
case models.IntegrationActionAddTag, models.IntegrationActionRemoveTag:
catID, perr := uuid.Parse(cfg.CategoryID)
if perr != nil {
return fmt.Errorf("a tag action needs a tag")
}
owner, oerr := s.native.OrgOwner(ctx, a.OrganizationID)
if oerr != nil {
return oerr
}
if n.Action == models.IntegrationActionAddTag {
return s.native.AddTag(ctx, a.OrganizationID, owner, c.ID, catID)
}
return s.native.RemoveTag(ctx, a.OrganizationID, owner, c.ID, catID)
case models.IntegrationActionMoveDealStage:
pid, e1 := uuid.Parse(cfg.DealPipelineID)
sid, e2 := uuid.Parse(cfg.DealStageID)
if e1 != nil || e2 != nil {
return fmt.Errorf("move-deal-stage needs a pipeline and stage")
}
return s.native.MoveDealStage(ctx, a.OrganizationID, c.ID, pid, sid)
case models.IntegrationActionCreateDeal:
pid, e1 := uuid.Parse(cfg.DealPipelineID)
sid, e2 := uuid.Parse(cfg.DealStageID)
if e1 != nil || e2 != nil {
return fmt.Errorf("create-deal needs a pipeline and stage")
}
owner, oerr := s.native.OrgOwner(ctx, a.OrganizationID)
if oerr != nil {
return oerr
}
name := renderTemplate(cfg.DealName, data)
if name == "" {
name = "Deal: " + c.Email
}
currency := cfg.DealCurrency
if currency == "" {
currency = "USD"
}
cid := c.ID
return s.native.CreateDeal(ctx, a.OrganizationID, owner, &models.CreateDeal{
PipelineID: pid,
StageID: sid,
ContactID: &cid,
Name: name,
Value: cfg.DealValue,
Currency: currency,
})
case models.IntegrationActionCreateTask:
owner, oerr := s.native.OrgOwner(ctx, a.OrganizationID)
if oerr != nil {
return oerr
}
title := renderTemplate(cfg.TaskTitle, data)
if title == "" {
title = "Follow up: " + c.Email
}
cid := c.ID
task := &models.CreateCRMTask{
ContactID: &cid,
Title: title,
Type: cfg.TaskType,
Priority: cfg.TaskPriority,
}
// Assignee: a specific user, or a whole team, or (default) the org owner.
if tid, perr := uuid.Parse(strings.TrimSpace(cfg.TaskAssignedTeamID)); perr == nil {
task.AssignedTeamID = &tid
}
if uid, perr := uuid.Parse(strings.TrimSpace(cfg.TaskAssignedTo)); perr == nil {
task.AssignedTo = &uid
} else if task.AssignedTeamID == nil {
task.AssignedTo = &owner
}
if cfg.TaskDueOffsetDays != nil {
due := time.Now().UTC().AddDate(0, 0, *cfg.TaskDueOffsetDays)
task.DueDate = &due
}
return s.native.CreateTask(ctx, a.OrganizationID, owner, task)
}
return fmt.Errorf("unknown native action: %s", n.Action)
}