conduyt 1.66.0 → 1.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/action-config-typos.js +78 -0
- package/dist/client.js +46 -3
- package/dist/index.js +86 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ The saved config lives at `~/.conduyt/config.json` and is written owner-only (`c
|
|
|
32
32
|
```bash
|
|
33
33
|
conduyt whoami # show the authenticated user
|
|
34
34
|
conduyt contacts list --limit 25 # list contacts (supports --cursor, --query)
|
|
35
|
-
conduyt contacts get <id> # fetch one contact (carries `revision`)
|
|
35
|
+
conduyt contacts get <id> # fetch one contact (carries `revision` and `hasOutboundSms`, a proven send only)
|
|
36
36
|
conduyt contacts update <id> --first Ann --expected-revision <n> # PATCH; a contact changed since revision n is refused: 409 contact_changed, exit 1, code and current revision printed
|
|
37
37
|
conduyt contacts collaborators add <contactId> <userId> [--expected-revision <n>] # POST /contacts/{id}/collaborators; prints { added, revision }
|
|
38
38
|
conduyt contacts collaborators remove <contactId> <userId> [--expected-revision <n>] # DELETE with a body; prints { removed, revision }; 404 when not a collaborator
|
|
@@ -78,15 +78,18 @@ conduyt whatsapp connect <senderSid> # connect a sender (verified with Twilio
|
|
|
78
78
|
conduyt whatsapp templates # approved / pending / rejected templates with their {{n}} placeholders
|
|
79
79
|
conduyt dialer voice-drop --contact <id> [--recording "Missed you"] [--if-human play] # a call that plays a Voicemail Drop recording to a machine
|
|
80
80
|
conduyt messages send-sms --contact <id> --transport whatsapp --whatsapp-template HX… --whatsapp-variables '{"1":"Alex"}' --body "…"
|
|
81
|
+
conduyt messages check --channel sms --body "…" [--context first|conversation] [--body-file draft.txt] # grade a DRAFT before sending (score, grade, flagged words with safer alternatives, Unicode code-point offsets); educates, never blocks, nothing stored or sent; 20 per minute; 400 / 403 / 413 / 429 exit 1 with the code
|
|
81
82
|
conduyt privacy export <id> # GDPR data-portability export (owner/admin)
|
|
82
83
|
conduyt privacy forget <id> --confirm FORGET # GDPR erasure — IRREVERSIBLE, owner only
|
|
83
84
|
conduyt reply-capture status # inbound reply capture: state, DNS records, provider health
|
|
84
85
|
conduyt reply-capture setup # create the receiving subdomain (--domain optional)
|
|
85
86
|
conduyt reply-capture verify # promote it to live once DNS is confirmed
|
|
86
87
|
conduyt reply-capture remove --confirm REMOVE # remove capture — replies to already-sent email are lost
|
|
88
|
+
conduyt integrations wordpress-forms status # is any intake address active, and how many ({ hasActive, activeCount }; count only, read-only, integrations scope)
|
|
87
89
|
conduyt integrations wordpress-forms list # WordPress form intake addresses: full URL of every active one, received counts
|
|
88
90
|
conduyt integrations wordpress-forms create --name "Contact page" --default-tag "Website lead" # prints the full URL
|
|
89
91
|
conduyt integrations wordpress-forms revoke <id> # the address answers 404 from now on; the row is kept
|
|
92
|
+
conduyt lifecycle set-booking-creates-own-card --enable # a booking creates its own card owned by the host instead of reusing the contact's open intake card (--disable = default; `lifecycle settings` shows bookingCreatesOwnCard)
|
|
90
93
|
conduyt api GET /api/v1/companies # raw authenticated request (escape hatch)
|
|
91
94
|
conduyt config show # show resolved config (key masked)
|
|
92
95
|
```
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local typo guard for automation action configs (card ownership per lane, Codex r1/r2). The server validator is the authority
|
|
3
|
+
* and there the option names are not enforced yet, so a typoed `laneSoruce` on assign_to_user would silently assign the run's
|
|
4
|
+
* deal instead of the lane card. Only the options below are known here.
|
|
5
|
+
*
|
|
6
|
+
* Traversal rule: ONLY the real action positions of the graph are visited, exactly as the graph is shaped: a v1 `actions`
|
|
7
|
+
* array's elements `{type, config}`, and a v2 graph's `nodes[].action` `{type, config}`. For each action of a known type the
|
|
8
|
+
* IMMEDIATE keys of `config` are checked; the config VALUES are opaque and never descended into (a webhook payload may hold any
|
|
9
|
+
* object, including one shaped like an action), and nothing outside those positions (request metadata, other node fields) is
|
|
10
|
+
* scanned. A key that is not a known option but is a case-insensitive match or ONE edit (substitution, insertion, deletion or
|
|
11
|
+
* adjacent transposition) from one is refused; keys further away, and every other action type, pass through.
|
|
12
|
+
* Source of the names: lib/automation-action-registry.ts in the app (assign_to_user, create_deal, close_other_open_deals).
|
|
13
|
+
*/
|
|
14
|
+
const KNOWN_ACTION_OPTIONS = {
|
|
15
|
+
assign_to_user: ["runWindow", "mode", "userId", "userIds", "groupId", "target", "applyTo", "overwriteExisting", "laneSource", "lanePipelineId", "laneStageId"],
|
|
16
|
+
create_deal: ["pipelineId", "stageId", "dealName", "dealValue", "allowDuplicates", "status", "source", "assignedTo", "leaveUnassigned"],
|
|
17
|
+
close_other_open_deals: ["lostReason"],
|
|
18
|
+
};
|
|
19
|
+
/** Damerau-Levenshtein (optimal string alignment) distance, case-insensitive */
|
|
20
|
+
function distance(a, b) {
|
|
21
|
+
const x = a.toLowerCase(), y = b.toLowerCase();
|
|
22
|
+
const d = Array.from({ length: x.length + 1 }, (_, i) => [i, ...Array(y.length).fill(0)]);
|
|
23
|
+
for (let j = 1; j <= y.length; j++)
|
|
24
|
+
d[0][j] = j;
|
|
25
|
+
for (let i = 1; i <= x.length; i++) {
|
|
26
|
+
for (let j = 1; j <= y.length; j++) {
|
|
27
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (x[i - 1] === y[j - 1] ? 0 : 1));
|
|
28
|
+
if (i > 1 && j > 1 && x[i - 1] === y[j - 2] && x[i - 2] === y[j - 1])
|
|
29
|
+
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return d[x.length][y.length];
|
|
33
|
+
}
|
|
34
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
35
|
+
function checkAction(action) {
|
|
36
|
+
if (!isObject(action) || typeof action.type !== "string" || !isObject(action.config))
|
|
37
|
+
return null;
|
|
38
|
+
const known = KNOWN_ACTION_OPTIONS[action.type];
|
|
39
|
+
if (!known)
|
|
40
|
+
return null;
|
|
41
|
+
for (const key of Object.keys(action.config)) {
|
|
42
|
+
if (known.includes(key))
|
|
43
|
+
continue;
|
|
44
|
+
const near = known.find((k) => distance(key, k) <= 1);
|
|
45
|
+
if (near)
|
|
46
|
+
return { actionType: action.type, key, suggestion: near };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/** The first misspelled option key in an actions graph (v1 array of {type, config}, or v2 { nodes: [{ action: {type, config} }] }), or null */
|
|
51
|
+
export function findActionConfigTypo(graph) {
|
|
52
|
+
if (Array.isArray(graph)) {
|
|
53
|
+
for (const action of graph) {
|
|
54
|
+
const t = checkAction(action);
|
|
55
|
+
if (t)
|
|
56
|
+
return t;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (isObject(graph) && Array.isArray(graph.nodes)) {
|
|
61
|
+
for (const node of graph.nodes) {
|
|
62
|
+
const t = isObject(node) ? checkAction(node.action) : null;
|
|
63
|
+
if (t)
|
|
64
|
+
return t;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
/** The refusal text; the details object is the final token */
|
|
70
|
+
export function actionConfigTypoMessage(t) {
|
|
71
|
+
return `Unknown option "${t.key}" in ${t.actionType} config: did you mean "${t.suggestion}"? Nothing was sent. ${JSON.stringify(t)}`;
|
|
72
|
+
}
|
|
73
|
+
/** Throws (a plain Error) when the graph carries a misspelled option */
|
|
74
|
+
export function assertNoActionConfigTypo(graph) {
|
|
75
|
+
const t = findActionConfigTypo(graph);
|
|
76
|
+
if (t)
|
|
77
|
+
throw new Error(actionConfigTypoMessage(t));
|
|
78
|
+
}
|
package/dist/client.js
CHANGED
|
@@ -12,6 +12,42 @@ const ERROR_DETAILS_BOUND = 1000;
|
|
|
12
12
|
/** Error-body keys that are the actionable state itself and are always rendered WHOLE: HealthFixRefusal.row (the deal's
|
|
13
13
|
* current health row, which a caller needs to choose again). */
|
|
14
14
|
const ALWAYS_WHOLE_KEYS = new Set(["row"]);
|
|
15
|
+
/** Bounded scheme for a refusal's error LISTS (the automation write refusals: AutomationActionConfigKeyError.errors, the 422
|
|
16
|
+
* AutomationPreflightRefusal.data { message, errors[], warnings[] }) when they do not fit ERROR_DETAILS_BOUND: keep the first
|
|
17
|
+
* MAX_LISTED_ERRORS entries (warnings: MAX_LISTED_WARNINGS) with every string field capped at MAX_FIELD_CHARS, and close the list with
|
|
18
|
+
* { "_more": K } for the K entries dropped, so each kept error still carries its code, nodeId, field and suggestion text. */
|
|
19
|
+
const MAX_LISTED_ERRORS = 25;
|
|
20
|
+
const MAX_LISTED_WARNINGS = 10;
|
|
21
|
+
const MAX_FIELD_CHARS = 400;
|
|
22
|
+
const capText = (s) => (s.length > MAX_FIELD_CHARS ? s.slice(0, MAX_FIELD_CHARS) + "...[shortened]" : s);
|
|
23
|
+
const capEntry = (e) => {
|
|
24
|
+
if (typeof e === "string")
|
|
25
|
+
return capText(e);
|
|
26
|
+
if (e && typeof e === "object" && !Array.isArray(e))
|
|
27
|
+
return Object.fromEntries(Object.entries(e).map(([k, v]) => [k, typeof v === "string" ? capText(v) : v]));
|
|
28
|
+
return e;
|
|
29
|
+
};
|
|
30
|
+
const capList = (list, max) => [...list.slice(0, max).map(capEntry), ...(list.length > max ? [{ _more: list.length - max }] : [])];
|
|
31
|
+
function compactRefusalList(key, value) {
|
|
32
|
+
if (key === "errors" && Array.isArray(value))
|
|
33
|
+
return capList(value, MAX_LISTED_ERRORS);
|
|
34
|
+
if (key === "data" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
35
|
+
const d = value;
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [k, v] of Object.entries(d)) {
|
|
38
|
+
if (k === "message" && typeof v === "string")
|
|
39
|
+
out[k] = capText(v);
|
|
40
|
+
else if (k === "errors" && Array.isArray(v))
|
|
41
|
+
out[k] = capList(v, MAX_LISTED_ERRORS);
|
|
42
|
+
else if (k === "warnings" && Array.isArray(v))
|
|
43
|
+
out[k] = capList(v, MAX_LISTED_WARNINGS);
|
|
44
|
+
else if (v === null || typeof v !== "object")
|
|
45
|
+
out[k] = v;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
15
51
|
/** Everything but `error` as ONE valid JSON object: scalars first (a very long string is shortened inside its quotes), then
|
|
16
52
|
* nested objects whole while they fit ERROR_DETAILS_BOUND, plus ALWAYS_WHOLE_KEYS; one that does not fit is left out and
|
|
17
53
|
* named in `_omitted`. JSON is never cut mid-object. */
|
|
@@ -25,10 +61,17 @@ export function renderErrorDetails(body) {
|
|
|
25
61
|
out[k] = typeof v === "string" && v.length > ERROR_DETAILS_BOUND ? v.slice(0, ERROR_DETAILS_BOUND) + "...[shortened]" : v;
|
|
26
62
|
const omitted = [];
|
|
27
63
|
for (const [k, v] of entries.filter(([, v]) => !isScalar(v))) {
|
|
28
|
-
|
|
64
|
+
// an EMPTY list / object (the import refusal's `warnings: []`) always fits: it costs two characters and is never "omitted"
|
|
65
|
+
if (ALWAYS_WHOLE_KEYS.has(k) || JSON.stringify(v).length <= 2 || JSON.stringify({ ...out, [k]: v }).length <= ERROR_DETAILS_BOUND)
|
|
29
66
|
out[k] = v;
|
|
30
|
-
else
|
|
31
|
-
|
|
67
|
+
else {
|
|
68
|
+
// a refusal's error list never vanishes into `_omitted`: it is kept in the bounded scheme above (codes and suggestions intact)
|
|
69
|
+
const compact = compactRefusalList(k, v);
|
|
70
|
+
if (compact !== undefined)
|
|
71
|
+
out[k] = compact;
|
|
72
|
+
else
|
|
73
|
+
omitted.push(k);
|
|
74
|
+
}
|
|
32
75
|
}
|
|
33
76
|
if (omitted.length > 0)
|
|
34
77
|
out._omitted = omitted;
|
package/dist/index.js
CHANGED
|
@@ -79,7 +79,7 @@ contacts
|
|
|
79
79
|
.action(run(async (client, opts) => client.get(`/api/v1/contacts${buildQuery({ limit: opts.limit, cursor: opts.cursor, query: opts.query, master_status: opts.masterStatus, exclude_master_status: opts.excludeMasterStatus })}`)));
|
|
80
80
|
contacts
|
|
81
81
|
.command("get <id>")
|
|
82
|
-
.description("Get a single contact by id (includes `revision`: pass it as --expected-revision on `contacts update` / `contacts collaborators`)")
|
|
82
|
+
.description("Get a single contact by id (includes `revision`: pass it as --expected-revision on `contacts update` / `contacts collaborators`; `hasOutboundSms` is true when the workspace has ever PROVABLY texted the contact: a proven send only, not a draft, a queued row or a pre-send failure; use it to pick `messages check --context`: false = first, true = conversation)")
|
|
83
83
|
.action(run(async (client, id) => client.get(`/api/v1/contacts/${encodeURIComponent(id)}`)));
|
|
84
84
|
contacts
|
|
85
85
|
.command("create")
|
|
@@ -1843,6 +1843,45 @@ messages
|
|
|
1843
1843
|
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
1844
1844
|
return client.post("/api/v1/messages/sms/send", body);
|
|
1845
1845
|
}));
|
|
1846
|
+
messages
|
|
1847
|
+
.command("check")
|
|
1848
|
+
.description("Grade a DRAFT text or email before sending (POST /messages/check): the same check the contact composers run as the rep types. Prints the route's data (the standard envelope is unwrapped): { score 0-100, grade A-F, verdict, vclass, hits, subjectHits, checks, sms (a text's chars / segments / unicode / perSeg; null for an email), context, notices (when present), engine { version, updatedAt } }. Each hit: { start, end, word, level (r likely blocked, a risky, y caution, n heads up), label, cat, why, alts (safer wording; some are advice rather than a swap), restricted (prohibited in US business texting: no rewording makes it deliverable), rule }. start / end are Unicode CODE-POINT offsets into the submitted text (the body for hits, the subject for subjectHits), end exclusive; an emoji counts as one, so [...text].slice(start, end) in JavaScript or text[start:end] in Python is the word (not a UTF-16 index). The check EDUCATES and never blocks: nothing is stored and nothing is sent; merge tokens such as {{contact.firstName}} are never flagged. --context template (default) grades a reusable message, first the first text to a contact, conversation a reply in an existing thread (the next engine release skips the opt-out and business-name checks for first / conversation; today every context scores the same); `contacts get <id>` carries hasOutboundSms to choose. Limits: body 10,000 characters, subject 300, request 48 KB; 20 checks per minute per account. Every refusal exits 1 with the status, the API's message and its code, sent once and never retried: 400 invalid_body, 403 forbidden (the key's member may view neither contacts nor email templates, or the key's scopes do not reach the route: needs the contacts, emails, sms or messages scope, a read-tier key too), 413 message_too_long / body_too_large, 429 rate_limited (Retry-After shown in the error text)")
|
|
1849
|
+
.option("--channel <channel>", "sms | email (required)")
|
|
1850
|
+
.option("--subject <text>", "email only (300 characters at most); refused on a text")
|
|
1851
|
+
.option("--body <text>", "the draft text (10,000 characters at most)")
|
|
1852
|
+
.option("--body-file <path>", "read the draft text from a file (for long text; not with --body)")
|
|
1853
|
+
.option("--context <context>", "template (default) | first | conversation")
|
|
1854
|
+
.option("--json <json>", "full JSON body { channel, subject?, body, context? }, merged over the flags")
|
|
1855
|
+
.action(run(async (client, opts) => {
|
|
1856
|
+
if (opts.body !== undefined && opts.bodyFile !== undefined)
|
|
1857
|
+
throw new Error("Pass either --body or --body-file, not both.");
|
|
1858
|
+
const body = {};
|
|
1859
|
+
if (opts.channel !== undefined)
|
|
1860
|
+
body.channel = opts.channel;
|
|
1861
|
+
if (opts.subject !== undefined)
|
|
1862
|
+
body.subject = opts.subject;
|
|
1863
|
+
if (opts.body !== undefined)
|
|
1864
|
+
body.body = opts.body;
|
|
1865
|
+
if (opts.bodyFile !== undefined) {
|
|
1866
|
+
if (opts.bodyFile.trim() === "")
|
|
1867
|
+
throw new Error("--body-file must be a path (got an empty string).");
|
|
1868
|
+
try {
|
|
1869
|
+
body.body = readFileSync(opts.bodyFile, "utf8");
|
|
1870
|
+
}
|
|
1871
|
+
catch {
|
|
1872
|
+
throw new Error(`--body-file could not be read: ${opts.bodyFile}`);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
if (opts.context !== undefined)
|
|
1876
|
+
body.context = opts.context;
|
|
1877
|
+
if (opts.json !== undefined)
|
|
1878
|
+
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
1879
|
+
if (body.channel === undefined)
|
|
1880
|
+
throw new Error("--channel is required (sms | email).");
|
|
1881
|
+
if (body.body === undefined)
|
|
1882
|
+
throw new Error("The draft is required. Pass --body '<text>' or --body-file <path>.");
|
|
1883
|
+
return client.post("/api/v1/messages/check", body);
|
|
1884
|
+
}));
|
|
1846
1885
|
messages
|
|
1847
1886
|
.command("conversations")
|
|
1848
1887
|
.description("List conversation threads")
|
|
@@ -2042,6 +2081,8 @@ calendars
|
|
|
2042
2081
|
return client.post("/api/v1/calendars", body);
|
|
2043
2082
|
}));
|
|
2044
2083
|
// ---- automations (the "AI builds a workflow" loop) ----
|
|
2084
|
+
// (#124, automation unknown keys / types) the refusal codes the write paths answer; printed by the client as `Conduyt API <status>: ...` with the details object last, never retried
|
|
2085
|
+
const AUTOMATION_KEY_REFUSALS_NOTE = " Unknown action keys are refused, never ignored: an action config key the action does not read (a typo such as `laneSoruce`) answers code unknown_action_config_key with the node, the key and, when one is close, a suggestion (\"Did you mean `laneSource`?\"); an action `type` no registry entry defines answers unknown_action_type (with \"Did you mean\" when a registered type is close). `automations import` answers 400, `automations validate` answers 200 with valid false and the `errors` list (nodeId, field, code, message), `automations publish` answers 422 (the preflight refusal wraps data.message, data.errors, data.warnings). Read the suggestion, fix the key and send again; a stored workflow is never refused on read.";
|
|
2045
2086
|
const automations = program.command("automations").description("Build and run automations/workflows");
|
|
2046
2087
|
automations
|
|
2047
2088
|
.command("list")
|
|
@@ -2083,13 +2124,13 @@ automations
|
|
|
2083
2124
|
}));
|
|
2084
2125
|
automations
|
|
2085
2126
|
.command("validate")
|
|
2086
|
-
.description("Validate a workflow graph before saving. Provide the body via --json or --file (needs triggerEvent + graph). A delay node waits for a fixed duration by default; waitType \"until_field_date\" (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately).")
|
|
2127
|
+
.description("Validate a workflow graph before saving. Provide the body via --json or --file (needs triggerEvent + graph). A delay node waits for a fixed duration by default; waitType \"until_field_date\" (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately). Card ownership per lane: assign_to_user config {userId | userIds (round robin) | groupId, target|applyTo: contact|deal|both, overwriteExisting, laneSource, lanePipelineId, laneStageId}: laneSource names a card lane (the Deal.source its Auto-Create step wrote, e.g. \"GSM\", max 500 chars) and makes the deal leg assign the contact's newest OPEN card from that lane (inside lanePipelineId when set) instead of the run's deal; when the contact has none and lanePipelineId is set, the card is created there (laneStageId, else the pipeline's first stage) already owned by the user; lanePipelineId or laneStageId without laneSource, or laneStageId without lanePipelineId, is refused; a lane never applies to an Assign-to-contact step. create_deal config {pipelineId (required), stageId, dealName, dealValue, allowDuplicates, status, source, assignedTo, leaveUnassigned}: leaveUnassigned true leaves the card unassigned when no assignee is set (default false: the card goes to the lead's owner). close_other_open_deals config {lostReason (required)}: closes the contact's OTHER open cards as lost with that reason, keeping the run's own card (each moves to its pipeline's lost stage with the usual stage-change effects; cards already won/lost, in the DPP Post-Close pipeline or in a pipeline with no lost stage are skipped; a re-run closes nothing twice); it needs the run's own deal, so use a deal trigger or a find_opportunity step first." + AUTOMATION_KEY_REFUSALS_NOTE)
|
|
2087
2128
|
.option("--json <json>", "full request body as JSON")
|
|
2088
2129
|
.option("--file <path>", "read the request body from a JSON file")
|
|
2089
2130
|
.action(run(async (client, opts) => client.post("/api/v1/automations/validate", bodyFromJsonOrFile(opts))));
|
|
2090
2131
|
automations
|
|
2091
2132
|
.command("import")
|
|
2092
|
-
.description("Create a workflow (with optional name resolution). Provide the body via --json or --file (needs name + triggerEvent + graph). A sequence step is the action enroll_in_sequence with config {sequenceId, enrollAction: add (default) | remove}; add needs the sequence to allow automation enrollment. A delay node waits for a fixed duration by default; waitType \"until_field_date\" (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately).")
|
|
2133
|
+
.description("Create a workflow (with optional name resolution). Provide the body via --json or --file (needs name + triggerEvent + graph). A sequence step is the action enroll_in_sequence with config {sequenceId, enrollAction: add (default) | remove}; add needs the sequence to allow automation enrollment. A delay node waits for a fixed duration by default; waitType \"until_field_date\" (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately). Card ownership per lane: assign_to_user config {userId | userIds (round robin) | groupId, target|applyTo: contact|deal|both, overwriteExisting, laneSource, lanePipelineId, laneStageId}: laneSource names a card lane (the Deal.source its Auto-Create step wrote, e.g. \"GSM\", max 500 chars) and makes the deal leg assign the contact's newest OPEN card from that lane (inside lanePipelineId when set) instead of the run's deal; when the contact has none and lanePipelineId is set, the card is created there (laneStageId, else the pipeline's first stage) already owned by the user; lanePipelineId or laneStageId without laneSource, or laneStageId without lanePipelineId, is refused; a lane never applies to an Assign-to-contact step. create_deal config {pipelineId (required), stageId, dealName, dealValue, allowDuplicates, status, source, assignedTo, leaveUnassigned}: leaveUnassigned true leaves the card unassigned when no assignee is set (default false: the card goes to the lead's owner). close_other_open_deals config {lostReason (required)}: closes the contact's OTHER open cards as lost with that reason, keeping the run's own card (each moves to its pipeline's lost stage with the usual stage-change effects; cards already won/lost, in the DPP Post-Close pipeline or in a pipeline with no lost stage are skipped; a re-run closes nothing twice); it needs the run's own deal, so use a deal trigger or a find_opportunity step first." + AUTOMATION_KEY_REFUSALS_NOTE)
|
|
2093
2134
|
.option("--json <json>", "full request body as JSON")
|
|
2094
2135
|
.option("--file <path>", "read the request body from a JSON file")
|
|
2095
2136
|
.option("--resolve-names", "set resolveNames=true so name fields in node configs are converted to ids")
|
|
@@ -2117,10 +2158,25 @@ automations
|
|
|
2117
2158
|
}));
|
|
2118
2159
|
automations
|
|
2119
2160
|
.command("publish <id>")
|
|
2120
|
-
.description("Publish a draft workflow to live")
|
|
2121
|
-
.
|
|
2161
|
+
.description("Publish a draft workflow to live. The API binds a publish to the draft you reviewed and answers 409 without its configRevision, so this reads GET /automations/<id> and POSTs its configRevision as expectedConfigRevision (--expected-config-revision N uses the revision you reviewed and skips the read; a draft changed since then is refused 409). A draft holding an action config key the action does not read, or an unregistered action type, is refused 422 with code unknown_action_config_key / unknown_action_type listed in data.errors (with a \"Did you mean\" suggestion); the error text prints the whole refusal")
|
|
2162
|
+
.option("--expected-config-revision <n>", "the configRevision of the draft you reviewed (from `automations` read / GET /automations/<id>); omit to read the current one")
|
|
2163
|
+
.action(run(async (client, id, opts) => {
|
|
2122
2164
|
assertUuid(id, "automation id");
|
|
2123
|
-
|
|
2165
|
+
let revision;
|
|
2166
|
+
if (opts.expectedConfigRevision !== undefined) {
|
|
2167
|
+
if (!/^\d+$/.test(opts.expectedConfigRevision.trim()))
|
|
2168
|
+
throw new Error("--expected-config-revision must be a non-negative integer (the configRevision of the draft you reviewed).");
|
|
2169
|
+
revision = Number(opts.expectedConfigRevision.trim());
|
|
2170
|
+
}
|
|
2171
|
+
else {
|
|
2172
|
+
// GET /automations/:id answers { data: { ...automation, configRevision } }
|
|
2173
|
+
const read = (await client.get(`/api/v1/automations/${encodeURIComponent(id)}`));
|
|
2174
|
+
const rev = read?.data?.configRevision;
|
|
2175
|
+
if (typeof rev !== "number")
|
|
2176
|
+
throw new Error("The automation read carried no configRevision, so the publish cannot be bound to the draft. Nothing was published.");
|
|
2177
|
+
revision = rev;
|
|
2178
|
+
}
|
|
2179
|
+
return client.post(`/api/v1/automations/${encodeURIComponent(id)}/publish`, { expectedConfigRevision: revision });
|
|
2124
2180
|
}));
|
|
2125
2181
|
automations
|
|
2126
2182
|
.command("step-logs [id]")
|
|
@@ -2784,6 +2840,10 @@ const integrations = program.command("integrations").description("Integrations m
|
|
|
2784
2840
|
const wordpressForms = integrations
|
|
2785
2841
|
.command("wordpress-forms")
|
|
2786
2842
|
.description("WordPress form intake addresses (Gravity Forms, WPForms, Elementor): submissions become contacts (form-intakes + contacts scopes, write tier; revoke: form-intakes)");
|
|
2843
|
+
wordpressForms
|
|
2844
|
+
.command("status")
|
|
2845
|
+
.description("Whether any intake address is ACTIVE and how many: { data: { hasActive, activeCount } } (GET /integrations/wordpress-forms/status). A boolean and a count only, never an address or token; read-only, API key scope integrations (a member: settings.view). A 403 exits 1 with the API's message")
|
|
2846
|
+
.action(run(async (client) => client.get("/api/v1/integrations/wordpress-forms/status")));
|
|
2787
2847
|
wordpressForms
|
|
2788
2848
|
.command("list")
|
|
2789
2849
|
.description("List every active intake address (newest first), then the revoked history (the newest 200 revocations), with the full URL of every active intake and received counts")
|
|
@@ -3996,6 +4056,14 @@ lifecycle
|
|
|
3996
4056
|
enabled: lostRaw?.enabled === true,
|
|
3997
4057
|
enabledAt: typeof lostRaw?.enabledAt === "string" ? lostRaw.enabledAt : null,
|
|
3998
4058
|
},
|
|
4059
|
+
// (card ownership per lane, 10-09) false = a booking uses the contact's existing open card in the intake pipeline. A stored value
|
|
4060
|
+
// is shown as is; absent in a FULL snapshot (never saved; the runtime treats it as false) is false; absent in a REDUCED snapshot
|
|
4061
|
+
// (a caller without settings.view) is null + note. The full view always carries `accountId` and the reduced one never does.
|
|
4062
|
+
...(typeof raw.bookingCreatesOwnCard === "boolean"
|
|
4063
|
+
? { bookingCreatesOwnCard: raw.bookingCreatesOwnCard }
|
|
4064
|
+
: typeof raw.accountId === "string"
|
|
4065
|
+
? { bookingCreatesOwnCard: false }
|
|
4066
|
+
: { bookingCreatesOwnCard: null, bookingCreatesOwnCardNote: "not visible to this role (needs settings.view)" }),
|
|
3999
4067
|
masterStatuses: {
|
|
4000
4068
|
defaults: DEFAULTS,
|
|
4001
4069
|
customs,
|
|
@@ -4038,6 +4106,18 @@ lifecycle
|
|
|
4038
4106
|
throw new Error("Pass --enable or --disable.");
|
|
4039
4107
|
return patchGeneralSettings(client, { lostPropagation: { enabled: Boolean(opts.enable) } });
|
|
4040
4108
|
}));
|
|
4109
|
+
lifecycle
|
|
4110
|
+
.command("set-booking-creates-own-card")
|
|
4111
|
+
.description("Set settings.bookingCreatesOwnCard (card ownership per lane; PATCH /settings, needs settings:edit). --enable: a booking creates a NEW card in the intake-deals pipeline and stage, owned by the booking's host, instead of using the contact's existing open card there. --disable (the default): the contact's existing open card is used. `lifecycle settings` shows the current value. A stale settings revision exits 1 with the conflict and the current settings")
|
|
4112
|
+
.option("--enable", "each booking creates its own card owned by the host")
|
|
4113
|
+
.option("--disable", "reuse the contact's open intake card (default)")
|
|
4114
|
+
.action(run(async (client, opts) => {
|
|
4115
|
+
if (opts.enable && opts.disable)
|
|
4116
|
+
throw new Error("Pass either --enable or --disable, not both.");
|
|
4117
|
+
if (opts.enable === undefined && opts.disable === undefined)
|
|
4118
|
+
throw new Error("Pass --enable or --disable.");
|
|
4119
|
+
return patchGeneralSettings(client, { bookingCreatesOwnCard: Boolean(opts.enable) });
|
|
4120
|
+
}));
|
|
4041
4121
|
lifecycle
|
|
4042
4122
|
.command("set-intake-deals")
|
|
4043
4123
|
.description("Configure automatic deal creation on live intake (webhooks, public forms, public bookings). Even under --re-inbound always: bulk imports and staff/calendar/dialer-created appointments never mint, exact replays and same-source events within 10 min are skipped, and terminal-status contacts get no new deals")
|
package/package.json
CHANGED