mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
531 lines
23 KiB
TypeScript
531 lines
23 KiB
TypeScript
// Trigger + action vocabulary the Automations flow builder renders from. Kept in
|
|
// sync with the backend's subscribable events (catalog crmEvents/notifyEvents)
|
|
// and the per-provider action handlers.
|
|
|
|
import { EVENT_LABELS, REPLY_INTENT_OPTIONS } from "@/lib/api/models/app/integrations/Integration";
|
|
import type { AutomationCondition } from "@/lib/api/models/app/automations/Automation";
|
|
|
|
// Warmbly events that can trigger an automation. Order = how they're listed.
|
|
// "campaign.action" is the manual / campaign-launched trigger: it never fires on
|
|
// a real event, only via a campaign "Run automation" step (RunAutomationByID).
|
|
export const TRIGGER_EVENTS: string[] = [
|
|
"campaign.reply_received",
|
|
"contact.created",
|
|
"form.submitted",
|
|
"meeting.booked",
|
|
"meeting.rescheduled",
|
|
"meeting.canceled",
|
|
"campaign.email_bounced",
|
|
"campaign.unsubscribed",
|
|
"warmup.health_changed",
|
|
"deliverability.complaint",
|
|
"inbound.webhook",
|
|
"campaign.action",
|
|
];
|
|
|
|
export function triggerLabel(ev: string): string {
|
|
return EVENT_LABELS[ev] ?? ev;
|
|
}
|
|
|
|
// The inbound-webhook trigger fires when an external system POSTs to this
|
|
// automation's unique URL; the JSON body becomes the event payload, so its
|
|
// fields are caller-defined rather than fixed.
|
|
export function triggerIsInboundWebhook(ev: string): boolean {
|
|
return ev === "inbound.webhook";
|
|
}
|
|
|
|
// Human labels for the provider action handlers (the action a step performs).
|
|
export const ACTION_LABELS: Record<string, string> = {
|
|
"slack.notify": "Send a Slack message",
|
|
"discord.notify": "Send a Discord message",
|
|
"hubspot.upsert_contact": "Create / update HubSpot contact",
|
|
"pipedrive.upsert_person": "Create / update Pipedrive person",
|
|
"salesforce.upsert_contact": "Create / update Salesforce contact",
|
|
"close.upsert_lead": "Create / update Close lead",
|
|
"webhook.ping": "Send a webhook",
|
|
// Native (Warmbly built-in) actions — no external connection needed.
|
|
"warmbly.add_tag": "Add a tag",
|
|
"warmbly.remove_tag": "Remove a tag",
|
|
"warmbly.create_task": "Create a task",
|
|
"warmbly.create_deal": "Create a deal",
|
|
"warmbly.move_deal_stage": "Move the deal stage",
|
|
"warmbly.unsubscribe": "Unsubscribe the contact",
|
|
"warmbly.run_automation": "Run another automation",
|
|
"warmbly.label_email": "Label the email",
|
|
"warmbly.set_variables": "Set variables",
|
|
"warmbly.fire_event": "Fire event",
|
|
"warmbly.upsert_contact": "Create or update contact",
|
|
"warmbly.add_to_campaign": "Add to campaign",
|
|
"warmbly.ai_step": "AI step",
|
|
"warmbly.ai_switch": "AI switch",
|
|
};
|
|
|
|
export function actionLabel(a: string): string {
|
|
return ACTION_LABELS[a] ?? a;
|
|
}
|
|
|
|
// Native (Warmbly-internal) actions operate on the event's contact directly,
|
|
// with no external connection. The "__native__" sentinel is the connection-select
|
|
// value that switches the action editor into native mode.
|
|
export const NATIVE_CONNECTION = "__native__";
|
|
|
|
export const NATIVE_ACTIONS: string[] = [
|
|
"warmbly.upsert_contact",
|
|
"warmbly.add_to_campaign",
|
|
"warmbly.add_tag",
|
|
"warmbly.remove_tag",
|
|
"warmbly.create_task",
|
|
"warmbly.create_deal",
|
|
"warmbly.move_deal_stage",
|
|
"warmbly.unsubscribe",
|
|
"warmbly.run_automation",
|
|
"warmbly.label_email",
|
|
"warmbly.set_variables",
|
|
"warmbly.fire_event",
|
|
"warmbly.ai_step",
|
|
"warmbly.ai_switch",
|
|
];
|
|
|
|
// AI_ALLOWLIST_ACTIONS is the closed set of reversible native actions an
|
|
// agent-mode AI step may be permitted to call. One source of truth for the
|
|
// editor checklist; it cannot express a send/reply action by construction.
|
|
export const AI_ALLOWLIST_ACTIONS = [
|
|
"warmbly.add_tag",
|
|
"warmbly.remove_tag",
|
|
"warmbly.create_task",
|
|
"warmbly.create_deal",
|
|
"warmbly.move_deal_stage",
|
|
"warmbly.label_email",
|
|
"warmbly.set_variables",
|
|
"warmbly.unsubscribe",
|
|
] as const;
|
|
|
|
export type NativeActionAllow = (typeof AI_ALLOWLIST_ACTIONS)[number];
|
|
|
|
// AI action nodes run over the event with the model: the unified AI step (a
|
|
// single-shot transform or an agent) or the AI switch router. They cost credits.
|
|
export function isAIAction(a: string): boolean {
|
|
return a === "warmbly.ai_step" || a === "warmbly.ai_switch";
|
|
}
|
|
|
|
export function isNativeAction(a: string): boolean {
|
|
return a.startsWith("warmbly.");
|
|
}
|
|
|
|
// What config a native action needs, so the editor shows the right picker.
|
|
export function nativeActionNeeds(
|
|
action: string,
|
|
):
|
|
| "tag"
|
|
| "label"
|
|
| "deal"
|
|
| "task"
|
|
| "automation"
|
|
| "vars"
|
|
| "event"
|
|
| "contact"
|
|
| "campaign"
|
|
| "ai_step"
|
|
| "ai_switch"
|
|
| "none" {
|
|
switch (action) {
|
|
case "warmbly.upsert_contact":
|
|
return "contact";
|
|
case "warmbly.add_to_campaign":
|
|
return "campaign";
|
|
case "warmbly.ai_step":
|
|
return "ai_step";
|
|
case "warmbly.ai_switch":
|
|
return "ai_switch";
|
|
case "warmbly.add_tag":
|
|
case "warmbly.remove_tag":
|
|
return "tag";
|
|
case "warmbly.label_email":
|
|
return "label";
|
|
case "warmbly.create_deal":
|
|
case "warmbly.move_deal_stage":
|
|
return "deal";
|
|
case "warmbly.create_task":
|
|
return "task";
|
|
case "warmbly.run_automation":
|
|
return "automation";
|
|
case "warmbly.set_variables":
|
|
return "vars";
|
|
case "warmbly.fire_event":
|
|
return "event";
|
|
default:
|
|
return "none";
|
|
}
|
|
}
|
|
|
|
// Which triggers carry an inbox thread, so a "label email" action has something
|
|
// to label (the reply event payload includes thread_id + the mailbox owner).
|
|
export function triggerCarriesThread(ev: string): boolean {
|
|
return ev === "campaign.reply_received";
|
|
}
|
|
|
|
// Per-action config field needs, so the node editor shows the right inputs.
|
|
export function actionNeedsChannel(action: string): boolean {
|
|
return action === "slack.notify";
|
|
}
|
|
export function actionNeedsURL(action: string): boolean {
|
|
return action === "webhook.ping";
|
|
}
|
|
// Notify-style actions accept an optional custom message template.
|
|
export function actionSupportsTemplate(action: string): boolean {
|
|
return action === "slack.notify" || action === "discord.notify" || action === "webhook.ping";
|
|
}
|
|
|
|
// Only the reply trigger carries intent / confidence filters.
|
|
export function triggerSupportsIntentFilter(ev: string): boolean {
|
|
return ev === "campaign.reply_received";
|
|
}
|
|
|
|
// --- Condition (IF) vocabulary — data-driven, per trigger -------------------
|
|
// An IF tests a key from the trigger's event payload with an operator. Each
|
|
// trigger exposes the fields its payload actually carries (so conditions are
|
|
// always meaningful), plus a universal "Random split". Adding an event here +
|
|
// to TRIGGER_VARIABLES is all it takes for new triggers to get full conditions.
|
|
|
|
export type ConditionValueType = "string" | "number" | "bool" | "enum";
|
|
|
|
export interface TriggerFieldDef {
|
|
key: string;
|
|
label: string;
|
|
type: ConditionValueType;
|
|
options?: { value: string; label: string }[];
|
|
defaultOperator: string;
|
|
}
|
|
|
|
// Sentinel field key for the random-split pseudo-condition.
|
|
export const RANDOM_FIELD_KEY = "__random__";
|
|
|
|
// Sentinel field key for the advanced free-form expression condition.
|
|
export const EXPRESSION_FIELD_KEY = "__expression__";
|
|
|
|
// Sentinel field key for the Ask-AI yes/no condition.
|
|
export const AI_FIELD_KEY = "__ai__";
|
|
|
|
export const WARMUP_STATES = [
|
|
{ value: "healthy", label: "Healthy" },
|
|
{ value: "watch", label: "Watch" },
|
|
{ value: "throttled", label: "Throttled" },
|
|
{ value: "quarantined", label: "Quarantined" },
|
|
{ value: "blocked", label: "Blocked" },
|
|
];
|
|
|
|
const MEETING_FIELDS: TriggerFieldDef[] = [
|
|
{ key: "source", label: "Source", type: "string", defaultOperator: "equals" },
|
|
{ key: "invitee_email", label: "Invitee email", type: "string", defaultOperator: "contains" },
|
|
{ key: "event_name", label: "Meeting name", type: "string", defaultOperator: "contains" },
|
|
{ key: "contact_id", label: "Matched contact", type: "string", defaultOperator: "exists" },
|
|
];
|
|
|
|
const DELIVERABILITY_FIELDS: TriggerFieldDef[] = [
|
|
{ key: "event_type", label: "Event type", type: "string", defaultOperator: "equals" },
|
|
{ key: "provider", label: "Provider", type: "string", defaultOperator: "equals" },
|
|
{ key: "reason", label: "Reason", type: "string", defaultOperator: "contains" },
|
|
{ key: "contact_email", label: "Contact email", type: "string", defaultOperator: "contains" },
|
|
{ key: "campaign_id", label: "From a campaign", type: "string", defaultOperator: "exists" },
|
|
];
|
|
|
|
export const CONTACT_SOURCES = [
|
|
{ value: "manual", label: "Added manually" },
|
|
{ value: "campaign", label: "Added from a campaign" },
|
|
{ value: "api", label: "API" },
|
|
{ value: "form", label: "Form submission" },
|
|
{ value: "automation", label: "Automation" },
|
|
{ value: "ai_assistant", label: "AI assistant" },
|
|
];
|
|
|
|
export const TRIGGER_FIELDS: Record<string, TriggerFieldDef[]> = {
|
|
"contact.created": [
|
|
{ key: "source", label: "Source", type: "enum", options: CONTACT_SOURCES, defaultOperator: "equals" },
|
|
{ key: "source_detail", label: "Source detail", type: "string", defaultOperator: "contains" },
|
|
{ key: "contact_email", label: "Contact email", type: "string", defaultOperator: "contains" },
|
|
{ key: "company", label: "Company", type: "string", defaultOperator: "contains" },
|
|
{ key: "phone", label: "Phone", type: "string", defaultOperator: "exists" },
|
|
{ key: "subscribed", label: "Subscribed", type: "bool", defaultOperator: "is_true" },
|
|
],
|
|
"form.submitted": [
|
|
{ key: "form_name", label: "Form", type: "string", defaultOperator: "equals" },
|
|
{ key: "contact_email", label: "Contact email", type: "string", defaultOperator: "contains" },
|
|
{ key: "company", label: "Company", type: "string", defaultOperator: "contains" },
|
|
{ key: "source_url", label: "Page URL", type: "string", defaultOperator: "contains" },
|
|
{ key: "campaign_id", label: "From a campaign", type: "string", defaultOperator: "exists" },
|
|
{ key: "contact_id", label: "Matched contact", type: "string", defaultOperator: "exists" },
|
|
],
|
|
"campaign.reply_received": [
|
|
{ key: "intent", label: "Reply intent", type: "enum", options: REPLY_INTENT_OPTIONS, defaultOperator: "equals" },
|
|
{ key: "confidence", label: "Classifier confidence", type: "number", defaultOperator: "gte" },
|
|
{ key: "contact_email", label: "Contact email", type: "string", defaultOperator: "contains" },
|
|
{ key: "subject", label: "Subject", type: "string", defaultOperator: "contains" },
|
|
{ key: "contact_id", label: "Matched contact", type: "string", defaultOperator: "exists" },
|
|
],
|
|
"meeting.booked": MEETING_FIELDS,
|
|
"meeting.rescheduled": MEETING_FIELDS,
|
|
"meeting.canceled": MEETING_FIELDS,
|
|
"campaign.email_bounced": DELIVERABILITY_FIELDS,
|
|
"deliverability.bounce": DELIVERABILITY_FIELDS,
|
|
"deliverability.complaint": DELIVERABILITY_FIELDS,
|
|
"campaign.unsubscribed": [
|
|
{ key: "source", label: "Unsubscribe source", type: "string", defaultOperator: "equals" },
|
|
{ key: "contact_email", label: "Contact email", type: "string", defaultOperator: "contains" },
|
|
{ key: "campaign_id", label: "From a campaign", type: "string", defaultOperator: "exists" },
|
|
],
|
|
"warmup.health_changed": [
|
|
{ key: "new_state", label: "New state", type: "enum", options: WARMUP_STATES, defaultOperator: "equals" },
|
|
{ key: "previous_state", label: "Previous state", type: "enum", options: WARMUP_STATES, defaultOperator: "equals" },
|
|
{ key: "email", label: "Mailbox", type: "string", defaultOperator: "contains" },
|
|
],
|
|
"campaign.action": [
|
|
{ key: "contact_email", label: "Contact email", type: "string", defaultOperator: "contains" },
|
|
{ key: "company", label: "Company", type: "string", defaultOperator: "contains" },
|
|
{ key: "campaign_id", label: "From a campaign", type: "string", defaultOperator: "exists" },
|
|
{ key: "contact_id", label: "Matched contact", type: "string", defaultOperator: "exists" },
|
|
],
|
|
};
|
|
|
|
const GENERIC_FIELDS: TriggerFieldDef[] = [
|
|
{ key: "contact_email", label: "Contact email", type: "string", defaultOperator: "contains" },
|
|
{ key: "contact_id", label: "Matched contact", type: "string", defaultOperator: "exists" },
|
|
];
|
|
|
|
const RANDOM_FIELD: TriggerFieldDef = {
|
|
key: RANDOM_FIELD_KEY,
|
|
label: "Random split",
|
|
type: "number",
|
|
defaultOperator: "chance",
|
|
};
|
|
|
|
const EXPRESSION_FIELD: TriggerFieldDef = {
|
|
key: EXPRESSION_FIELD_KEY,
|
|
label: "Advanced expression",
|
|
type: "string",
|
|
defaultOperator: "",
|
|
};
|
|
|
|
// Ask AI: a plain-language yes/no question answered by the model over the
|
|
// event data (true edge = yes). Costs 1 credit per evaluation.
|
|
const AI_FIELD: TriggerFieldDef = {
|
|
key: AI_FIELD_KEY,
|
|
label: "Ask AI (yes/no)",
|
|
type: "string",
|
|
defaultOperator: "",
|
|
};
|
|
|
|
// The condition fields offered for a trigger: its payload fields + Ask AI +
|
|
// random split + the advanced free-form expression escape hatch.
|
|
export function triggerConditionFields(triggerEvent: string): TriggerFieldDef[] {
|
|
return [...(TRIGGER_FIELDS[triggerEvent] ?? GENERIC_FIELDS), AI_FIELD, RANDOM_FIELD, EXPRESSION_FIELD];
|
|
}
|
|
|
|
export function triggerFieldDef(triggerEvent: string, key: string): TriggerFieldDef | undefined {
|
|
return triggerConditionFields(triggerEvent).find((f) => f.key === key);
|
|
}
|
|
|
|
// Build a fresh condition from a picked field key (random -> chance split;
|
|
// everything else -> a generic field test with the field's default operator).
|
|
export function conditionFromFieldKey(triggerEvent: string, key: string): AutomationCondition {
|
|
if (key === RANDOM_FIELD_KEY) return { field: "random", operator: "chance", value: 50 };
|
|
if (key === EXPRESSION_FIELD_KEY) return { field: "expression", operator: "", expression: "" };
|
|
if (key === AI_FIELD_KEY) return { field: "ai", operator: "", prompt: "" };
|
|
const def = triggerFieldDef(triggerEvent, key);
|
|
return { field: "field", key, operator: def?.defaultOperator ?? "equals" };
|
|
}
|
|
|
|
// The default condition a new IF node starts with for a given trigger.
|
|
export function defaultConditionForTrigger(triggerEvent: string): AutomationCondition {
|
|
const first = triggerConditionFields(triggerEvent)[0];
|
|
return conditionFromFieldKey(triggerEvent, first?.key ?? RANDOM_FIELD_KEY);
|
|
}
|
|
|
|
// The select value representing a condition's chosen field.
|
|
export function conditionFieldKey(c: AutomationCondition): string {
|
|
if (c.field === "random") return RANDOM_FIELD_KEY;
|
|
if (c.field === "expression") return EXPRESSION_FIELD_KEY;
|
|
if (c.field === "ai") return AI_FIELD_KEY;
|
|
if (c.field === "field") return c.key ?? "";
|
|
return c.field; // legacy
|
|
}
|
|
|
|
export const OPERATOR_LABELS: Record<string, string> = {
|
|
equals: "is",
|
|
not_equals: "is not",
|
|
contains: "contains",
|
|
gte: "≥",
|
|
lte: "≤",
|
|
exists: "is present",
|
|
is_true: "is true",
|
|
chance: "% of the time",
|
|
};
|
|
|
|
// Which operators make sense for each value type.
|
|
export function operatorsForType(type: ConditionValueType): { value: string; label: string }[] {
|
|
const ops =
|
|
type === "number"
|
|
? ["gte", "lte", "equals", "exists"]
|
|
: type === "enum"
|
|
? ["equals", "not_equals", "exists"]
|
|
: type === "bool"
|
|
? ["is_true", "exists"]
|
|
: ["equals", "not_equals", "contains", "exists"];
|
|
return ops.map((o) => ({ value: o, label: OPERATOR_LABELS[o] ?? o }));
|
|
}
|
|
|
|
export function defaultOperatorFor(field: string): string {
|
|
// Legacy helper kept for any old callers; new code reads field def operators.
|
|
switch (field) {
|
|
case "confidence":
|
|
return "gte";
|
|
case "has_contact":
|
|
return "is_true";
|
|
case "random":
|
|
return "chance";
|
|
default:
|
|
return "equals";
|
|
}
|
|
}
|
|
|
|
// --- Template variables per trigger (for the insert menus) ------------------
|
|
// The keys present in each trigger's event payload, offered as {{.key}} inserts
|
|
// (standard Go-template dotted field access) in action message/URL/value fields.
|
|
export const TRIGGER_VARIABLES: Record<string, string[]> = {
|
|
"contact.created": ["contact_email", "contact_id", "first_name", "last_name", "company", "phone", "source", "source_detail", "subscribed"],
|
|
"form.submitted": ["contact_email", "contact_id", "first_name", "last_name", "company", "phone", "form_name", "form_id", "submission_id", "source_url", "campaign_id"],
|
|
"campaign.reply_received": ["contact_email", "contact_id", "campaign_id", "intent", "confidence", "subject", "snippet"],
|
|
"meeting.booked": ["invitee_name", "invitee_email", "event_name", "scheduled_for", "join_url", "source", "contact_id"],
|
|
"meeting.rescheduled": ["invitee_name", "invitee_email", "event_name", "scheduled_for", "join_url", "source", "contact_id"],
|
|
"meeting.canceled": ["invitee_name", "invitee_email", "event_name", "scheduled_for", "source", "contact_id"],
|
|
"campaign.email_bounced": ["contact_email", "campaign_id", "contact_id", "event_type", "provider", "reason"],
|
|
"deliverability.bounce": ["contact_email", "campaign_id", "contact_id", "event_type", "provider", "reason"],
|
|
"deliverability.complaint": ["contact_email", "campaign_id", "contact_id", "event_type", "provider", "reason"],
|
|
"campaign.unsubscribed": ["contact_email", "contact_id", "campaign_id", "source"],
|
|
"warmup.health_changed": ["email", "new_state", "previous_state", "reason"],
|
|
"campaign.action": ["contact_email", "contact_id", "campaign_id", "campaign_name", "first_name", "last_name", "company", "phone"],
|
|
// Inbound webhook payloads are caller-defined, so there are no fixed
|
|
// variables to offer; the user references their own JSON keys directly.
|
|
"inbound.webhook": [],
|
|
};
|
|
|
|
export function triggerVariables(triggerEvent: string): string[] {
|
|
return TRIGGER_VARIABLES[triggerEvent] ?? ["contact_email", "contact_id"];
|
|
}
|
|
|
|
// A representative sample event payload for a trigger, mirroring the backend's
|
|
// sampleEventData (internal/app/integration/graph_executor.go). Seeds the test
|
|
// panel so a dry run has realistic data to evaluate conditions and render action
|
|
// previews against. The user can edit it freely before running.
|
|
export function sampleEventData(triggerEvent: string): Record<string, unknown> {
|
|
const base: Record<string, unknown> = {
|
|
contact_email: "jane@example.com",
|
|
contact_id: "00000000-0000-0000-0000-000000000001",
|
|
campaign_id: "00000000-0000-0000-0000-000000000002",
|
|
campaign_name: "Q3 Outbound",
|
|
first_name: "Jane",
|
|
last_name: "Doe",
|
|
company: "Example Inc",
|
|
};
|
|
switch (triggerEvent) {
|
|
case "campaign.reply_received":
|
|
base.intent = "positive";
|
|
base.confidence = 0.92;
|
|
base.subject = "Re: quick question";
|
|
base.snippet = "Sure, let's talk next week.";
|
|
break;
|
|
case "meeting.booked":
|
|
case "meeting.rescheduled":
|
|
case "meeting.canceled":
|
|
base.source = "calendly";
|
|
base.invitee_email = "jane@example.com";
|
|
base.invitee_name = "Jane Doe";
|
|
base.event_name = "Intro call";
|
|
base.scheduled_for = "2026-07-01T15:00:00Z";
|
|
base.join_url = "https://example.com/join/abc";
|
|
break;
|
|
case "campaign.email_bounced":
|
|
case "deliverability.bounce":
|
|
case "deliverability.complaint":
|
|
base.event_type = "bounce";
|
|
base.provider = "ses";
|
|
base.reason = "mailbox full";
|
|
break;
|
|
case "warmup.health_changed":
|
|
base.email = "sender@example.com";
|
|
base.new_state = "watch";
|
|
base.previous_state = "healthy";
|
|
base.reason = "spam placement rising";
|
|
break;
|
|
case "contact.created":
|
|
delete base.campaign_id;
|
|
delete base.campaign_name;
|
|
base.phone = "+1 555 0100";
|
|
base.subscribed = true;
|
|
base.custom_fields = { industry: "SaaS" };
|
|
base.source = "form";
|
|
base.source_detail = "Demo request";
|
|
base.campaign_ids = [];
|
|
base.category_ids = [];
|
|
break;
|
|
case "form.submitted":
|
|
delete base.campaign_name;
|
|
base.form_id = "00000000-0000-0000-0000-000000000003";
|
|
base.form_name = "Demo request";
|
|
base.submission_id = "00000000-0000-0000-0000-000000000004";
|
|
base.phone = "+1 555 0100";
|
|
base.source_url = "https://example.com/pricing";
|
|
base.data = { email: "jane@example.com", first_name: "Jane", last_name: "Doe", company: "Example Inc", team_size: "10-50" };
|
|
break;
|
|
}
|
|
return base;
|
|
}
|
|
|
|
const prettyKey = (k: string) => k.replace(/_/g, " ");
|
|
|
|
// A short human summary of a condition, used as the IF node label.
|
|
export function conditionLabel(c?: AutomationCondition): string {
|
|
if (!c || !c.field) return "Set a condition";
|
|
// Random split (its own field type).
|
|
if (c.field === "random") return `${Number(c.value ?? 50)}% random`;
|
|
// Advanced free-form expression.
|
|
if (c.field === "expression") {
|
|
const e = (c.expression ?? "").trim();
|
|
if (!e) return "Set an expression";
|
|
return `if ${e.length > 30 ? e.slice(0, 30) + "…" : e}`;
|
|
}
|
|
// Ask AI yes/no question.
|
|
if (c.field === "ai") {
|
|
const p = (c.prompt ?? "").trim();
|
|
if (!p) return "Ask AI a question";
|
|
return `AI: ${p.length > 30 ? p.slice(0, 30) + "…" : p}`;
|
|
}
|
|
// Generic field condition.
|
|
if (c.field === "field") {
|
|
const key = c.key ?? "";
|
|
const op = c.operator;
|
|
if (op === "exists") return `${prettyKey(key)} is present`;
|
|
if (op === "is_true") return `${prettyKey(key)} is true`;
|
|
if (key === "confidence") return `confidence ≥ ${Math.round(Number(c.value ?? 0) * 100)}%`;
|
|
const opLbl = OPERATOR_LABELS[op] ?? op;
|
|
const valLbl =
|
|
REPLY_INTENT_OPTIONS.find((o) => o.value === c.value)?.label ??
|
|
WARMUP_STATES.find((o) => o.value === c.value)?.label ??
|
|
(key === "source" ? CONTACT_SOURCES.find((o) => o.value === c.value)?.label : undefined) ??
|
|
String(c.value ?? "…");
|
|
return `${prettyKey(key)} ${opLbl} ${valLbl}`;
|
|
}
|
|
// Legacy semantic fields (older saved automations).
|
|
switch (c.field) {
|
|
case "confidence":
|
|
return `confidence ≥ ${Math.round(Number(c.value ?? 0) * 100)}%`;
|
|
case "has_contact":
|
|
return "has a contact";
|
|
case "intent":
|
|
return `intent is ${REPLY_INTENT_OPTIONS.find((o) => o.value === c.value)?.label ?? String(c.value ?? "…")}`;
|
|
case "source":
|
|
return `source is ${String(c.value ?? "…")}`;
|
|
default:
|
|
return c.field;
|
|
}
|
|
}
|