conduyt 1.41.0 → 1.43.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/dist/index.js +205 -6
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -180,6 +180,123 @@ contacts
|
|
|
180
180
|
return client.del(`/api/v1/contacts/${encodeURIComponent(id)}`);
|
|
181
181
|
}));
|
|
182
182
|
// ---- deals ----
|
|
183
|
+
contacts
|
|
184
|
+
.command("delete-matching")
|
|
185
|
+
.description("Soft-delete EVERY contact a list filter matches, across pages (#73): the contacts page's \"Select all N matching\" action. Give the same filter you would give `contacts list` (or --filter with a JSON object of list query parameters) plus --expected, the total that filter showed; the server re-counts and refuses (409) when the set moved, so you never delete rows you did not look at. Contacts with an upcoming appointment are kept and reported. The command repeats the call until the server reports done. Requires contacts:delete.")
|
|
186
|
+
.option("--search <text>", "free-text search")
|
|
187
|
+
.option("--tag <name>", "one tag")
|
|
188
|
+
.option("--tags <names>", "comma-separated tags")
|
|
189
|
+
.option("--source <source>", "lead source")
|
|
190
|
+
.option("--assigned-to <userId>", "assignee user id")
|
|
191
|
+
.option("--smart-view <id>", "Smart View id")
|
|
192
|
+
.option("--smart-list <id>", "Smart List id")
|
|
193
|
+
.option("--import-id <id>", "import job id")
|
|
194
|
+
.option("--master-status <statuses>", "comma-separated lead statuses to include")
|
|
195
|
+
.option("--filter <json>", "a JSON object of list query parameters (merged over the flags above)")
|
|
196
|
+
.requiredOption("--expected <n>", "the total the list showed for this filter")
|
|
197
|
+
.option("--yes", "skip the typed confirmation (scripts)")
|
|
198
|
+
.action(run(async (client, opts) => {
|
|
199
|
+
const filter = {};
|
|
200
|
+
if (typeof opts.search === "string")
|
|
201
|
+
filter.search = opts.search;
|
|
202
|
+
if (typeof opts.tag === "string")
|
|
203
|
+
filter.tag = opts.tag;
|
|
204
|
+
if (typeof opts.tags === "string")
|
|
205
|
+
filter.tags = opts.tags;
|
|
206
|
+
if (typeof opts.source === "string")
|
|
207
|
+
filter.source = opts.source;
|
|
208
|
+
if (typeof opts.assignedTo === "string")
|
|
209
|
+
filter.assigned_to = opts.assignedTo;
|
|
210
|
+
if (typeof opts.smartView === "string")
|
|
211
|
+
filter.smart_view = opts.smartView;
|
|
212
|
+
if (typeof opts.smartList === "string")
|
|
213
|
+
filter.smartListId = opts.smartList;
|
|
214
|
+
if (typeof opts.importId === "string")
|
|
215
|
+
filter.import_id = opts.importId;
|
|
216
|
+
if (typeof opts.masterStatus === "string")
|
|
217
|
+
filter.master_status = opts.masterStatus.split(",").map((s) => s.trim()).filter(Boolean);
|
|
218
|
+
if (typeof opts.filter === "string") {
|
|
219
|
+
let parsed;
|
|
220
|
+
try {
|
|
221
|
+
parsed = JSON.parse(opts.filter);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
throw new Error("--filter must be a JSON object of list query parameters.");
|
|
225
|
+
}
|
|
226
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
227
|
+
throw new Error("--filter must be a JSON object of list query parameters.");
|
|
228
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
229
|
+
if (typeof v === "string" || (Array.isArray(v) && v.every((x) => typeof x === "string")))
|
|
230
|
+
filter[k] = v;
|
|
231
|
+
else
|
|
232
|
+
throw new Error(`--filter.${k} must be a string or an array of strings.`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const hasValue = Object.values(filter).some((v) => (Array.isArray(v) ? v.some((x) => x.trim()) : v.trim()));
|
|
236
|
+
if (!hasValue)
|
|
237
|
+
throw new Error("Narrow the delete with at least one filter value; deleting every contact is not a bulk action.");
|
|
238
|
+
const expected = Number(opts.expected);
|
|
239
|
+
if (!Number.isInteger(expected) || expected < 1)
|
|
240
|
+
throw new Error("--expected must be the positive whole number the list showed for this filter.");
|
|
241
|
+
if (!opts.yes) {
|
|
242
|
+
const { createInterface } = await import("node:readline/promises");
|
|
243
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
244
|
+
const answer = (await rl.question(`Delete all ${expected} contacts matching this filter? Type the number ${expected} to confirm: `)).trim();
|
|
245
|
+
rl.close();
|
|
246
|
+
if (answer !== String(expected))
|
|
247
|
+
throw new Error("Cancelled: the typed number did not match.");
|
|
248
|
+
}
|
|
249
|
+
// The first call freezes the set on the server (409 when the count moved or when shared rows are not
|
|
250
|
+
// yours to delete); every later pass sends back the ids the server returned, never the filter again.
|
|
251
|
+
let body = { filter, expectedTotal: expected };
|
|
252
|
+
let deleted = 0;
|
|
253
|
+
let skipped = 0;
|
|
254
|
+
let kept = 0;
|
|
255
|
+
let last = {};
|
|
256
|
+
// A continuation that fails for a retryable reason (429 honouring retryAfter, a 5xx, a network error) is
|
|
257
|
+
// retried with the SAME ids so committed progress is never stranded.
|
|
258
|
+
const postWithRetry = async (payload) => {
|
|
259
|
+
for (let attempt = 0;; attempt++) {
|
|
260
|
+
try {
|
|
261
|
+
return (await client.post("/api/v1/bulk/contacts/delete-matching", payload));
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
265
|
+
const status = Number(/^Conduyt API (\d{3})/.exec(msg)?.[1] ?? 0);
|
|
266
|
+
const retryable = "ids" in payload && (status === 429 || status >= 500 || status === 0);
|
|
267
|
+
if (!retryable || attempt >= 5)
|
|
268
|
+
throw err;
|
|
269
|
+
const retryAfter = Number(/"retryAfter":(\d+)/.exec(msg)?.[1] ?? 0);
|
|
270
|
+
await new Promise((r) => setTimeout(r, status === 429 ? Math.max(5, Math.min(60, retryAfter)) * 1000 : 2000 * (attempt + 1)));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
for (let pass = 0; pass < 200; pass++) {
|
|
275
|
+
const res = await postWithRetry(body);
|
|
276
|
+
const d = (res?.data ?? res);
|
|
277
|
+
last = d;
|
|
278
|
+
deleted += Number(d.deleted ?? 0);
|
|
279
|
+
skipped += Number(d.skipped ?? 0);
|
|
280
|
+
kept += Number(d.keptWithAppointments ?? 0);
|
|
281
|
+
const remainingIds = Array.isArray(d.remainingIds) ? d.remainingIds : [];
|
|
282
|
+
if (d.done || remainingIds.length === 0)
|
|
283
|
+
break;
|
|
284
|
+
if (!d.busy && Number(d.deleted ?? 0) === 0)
|
|
285
|
+
break;
|
|
286
|
+
if (d.busy)
|
|
287
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
288
|
+
body = { ids: remainingIds, continuation: d.continuation ?? null };
|
|
289
|
+
}
|
|
290
|
+
const remaining = Number(last.remaining ?? 0);
|
|
291
|
+
return {
|
|
292
|
+
deleted,
|
|
293
|
+
skipped,
|
|
294
|
+
keptWithAppointments: kept,
|
|
295
|
+
done: last.done === true,
|
|
296
|
+
remaining,
|
|
297
|
+
...(remaining > 0 ? { note: "the run stopped before finishing; run the same command again (with the new --expected) to delete the rest" } : {}),
|
|
298
|
+
};
|
|
299
|
+
}));
|
|
183
300
|
const deals = program.command("deals").description("Manage deals");
|
|
184
301
|
deals
|
|
185
302
|
.command("list")
|
|
@@ -1877,6 +1994,65 @@ program
|
|
|
1877
1994
|
}));
|
|
1878
1995
|
// ---- dialer ----
|
|
1879
1996
|
const dialer = program.command("dialer").description("Dialer operations");
|
|
1997
|
+
// ---- curated agent statuses (#71, 2026-09-15) ----
|
|
1998
|
+
const AGENT_STATUS_CODES = ["available", "break", "lunch", "meeting", "training", "offline"];
|
|
1999
|
+
const FIXED_AGENT_STATUS_CODES = ["available", "offline"];
|
|
2000
|
+
const MAX_AGENT_STATUS_LABEL = 24;
|
|
2001
|
+
function agentStatusCodeArg(raw) {
|
|
2002
|
+
const code = raw.trim().toLowerCase();
|
|
2003
|
+
if (!AGENT_STATUS_CODES.includes(code))
|
|
2004
|
+
throw new Error(`'${raw}' is not a dialer status — one of ${AGENT_STATUS_CODES.join(", ")}.`);
|
|
2005
|
+
return code;
|
|
2006
|
+
}
|
|
2007
|
+
async function readAgentStatusesConfig(client) {
|
|
2008
|
+
const current = (await client.get("/api/v1/settings"));
|
|
2009
|
+
const stored = current?.data?.agentStatuses ?? {};
|
|
2010
|
+
const labels = Object.fromEntries(Object.entries(stored.labels ?? {}).filter((e) => typeof e[1] === "string"));
|
|
2011
|
+
const hidden = Array.isArray(stored.hidden) ? stored.hidden.filter((c) => typeof c === "string") : [];
|
|
2012
|
+
return { labels, hidden };
|
|
2013
|
+
}
|
|
2014
|
+
dialer
|
|
2015
|
+
.command("statuses")
|
|
2016
|
+
.description("List the account's curated dialer statuses (GET /dialer/agent-statuses): Available, Break, Lunch, Meeting, Training, Offline. Each carries a FIXED basis the dialer runs on (available = takes inbound calls and Smart Dialing hand-outs; break = signed in, not ringable; offline = signed out), the label reps see, and whether admins hid it from the softphone menu. Rename with `dialer rename-status`, hide/show with `dialer hide-status` / `dialer show-status`; Available and Offline can never be hidden. Reports bucket by basis.")
|
|
2017
|
+
.action(run(async (client) => client.get("/api/v1/dialer/agent-statuses")));
|
|
2018
|
+
dialer
|
|
2019
|
+
.command("rename-status <code> <label>")
|
|
2020
|
+
.description(`Rename a curated dialer status for this account (settings scope): e.g. \`rename-status break "Short break"\`. Pass \`default\` as the label to restore the built-in name. Labels are 1–${MAX_AGENT_STATUS_LABEL} characters; two visible statuses cannot share a label (422). Codes: ${AGENT_STATUS_CODES.join(", ")}. The other labels and the hidden list stay as they are.`)
|
|
2021
|
+
.action(run(async (client, codeRaw, labelRaw) => {
|
|
2022
|
+
const code = agentStatusCodeArg(codeRaw);
|
|
2023
|
+
const label = labelRaw.trim();
|
|
2024
|
+
if (!label)
|
|
2025
|
+
throw new Error("The label must not be blank; pass 'default' to restore the built-in name.");
|
|
2026
|
+
if (label.length > MAX_AGENT_STATUS_LABEL)
|
|
2027
|
+
throw new Error(`Labels are at most ${MAX_AGENT_STATUS_LABEL} characters.`);
|
|
2028
|
+
const next = await readAgentStatusesConfig(client);
|
|
2029
|
+
if (label.toLowerCase() === "default")
|
|
2030
|
+
delete next.labels[code];
|
|
2031
|
+
else
|
|
2032
|
+
next.labels[code] = label;
|
|
2033
|
+
return patchGeneralSettings(client, { agentStatuses: next });
|
|
2034
|
+
}));
|
|
2035
|
+
dialer
|
|
2036
|
+
.command("hide-status <code>")
|
|
2037
|
+
.description(`Hide a curated dialer status from the softphone menu (settings scope). Available and Offline cannot be hidden; a rep already on a hidden status keeps it until they change. Codes: ${AGENT_STATUS_CODES.join(", ")}.`)
|
|
2038
|
+
.action(run(async (client, codeRaw) => {
|
|
2039
|
+
const code = agentStatusCodeArg(codeRaw);
|
|
2040
|
+
if (FIXED_AGENT_STATUS_CODES.includes(code))
|
|
2041
|
+
throw new Error(`'${code}' cannot be hidden — every rep needs it.`);
|
|
2042
|
+
const next = await readAgentStatusesConfig(client);
|
|
2043
|
+
if (!next.hidden.includes(code))
|
|
2044
|
+
next.hidden.push(code);
|
|
2045
|
+
return patchGeneralSettings(client, { agentStatuses: next });
|
|
2046
|
+
}));
|
|
2047
|
+
dialer
|
|
2048
|
+
.command("show-status <code>")
|
|
2049
|
+
.description(`Put a hidden curated dialer status back in the softphone menu (settings scope). Codes: ${AGENT_STATUS_CODES.join(", ")}.`)
|
|
2050
|
+
.action(run(async (client, codeRaw) => {
|
|
2051
|
+
const code = agentStatusCodeArg(codeRaw);
|
|
2052
|
+
const next = await readAgentStatusesConfig(client);
|
|
2053
|
+
next.hidden = next.hidden.filter((c) => c !== code);
|
|
2054
|
+
return patchGeneralSettings(client, { agentStatuses: next });
|
|
2055
|
+
}));
|
|
1880
2056
|
dialer
|
|
1881
2057
|
.command("voice-drop")
|
|
1882
2058
|
.description("Place a voice drop to a contact (POST /calls/voice-drop): an outbound Twilio call with answering-machine detection that plays one of the account's Voicemail Drop recordings (Settings → Twilio → Voicemail Drop; names from `messages transports` voiceDrop.recordings) when a machine answers, and hangs up quietly when a person answers unless --if-human play. Governed like a call: calls-tier consent (a texting STOP does not block it, a DNC or litigator row does), calling hours, one live call per lead. Placed from your own Twilio line (else the account number) unless --from names a number the account owns. Returns 202 with the Call row; the outcome lands on it through the voice webhooks (disposition voicemail_drop = played to a machine, voice_drop_human = a person answered, voice_drop_live = played to a person). Refusals carry a code (dnc, litigator, calling_window, active_call, voice_drop_recording_missing, voice_drop_not_configured); 429 and 502 are safe to retry (dialer scope)")
|
|
@@ -1952,6 +2128,26 @@ replyCapture
|
|
|
1952
2128
|
return client.del("/api/v1/email-domains/reply-capture");
|
|
1953
2129
|
}));
|
|
1954
2130
|
// ---- lifecycle (master lead status + intake deals) ----
|
|
2131
|
+
// ---- general settings writes (09-15) ----
|
|
2132
|
+
// PATCH /api/v1/settings refuses a general-settings change without the settingsRevision the editor loaded
|
|
2133
|
+
// (400 expected_settings_revision_required) and answers 409 when another save landed first. Every general
|
|
2134
|
+
// write below reads the revision first and sends it; a 409 is re-raised with the current settings so the
|
|
2135
|
+
// caller re-derives the change instead of overwriting a newer save.
|
|
2136
|
+
async function patchGeneralSettings(client, body) {
|
|
2137
|
+
const read = async () => (await client.get("/api/v1/settings"));
|
|
2138
|
+
const before = await read();
|
|
2139
|
+
const expectedSettingsRevision = typeof before?.data?.settingsRevision === "string" ? before.data.settingsRevision : null;
|
|
2140
|
+
try {
|
|
2141
|
+
return await client.patch("/api/v1/settings", { ...body, ...(expectedSettingsRevision ? { expectedSettingsRevision } : {}) });
|
|
2142
|
+
}
|
|
2143
|
+
catch (err) {
|
|
2144
|
+
if (err instanceof Error && /^Conduyt API 409/.test(err.message)) {
|
|
2145
|
+
const current = await read();
|
|
2146
|
+
throw new Error(`${err.message} — the account settings changed since revision ${expectedSettingsRevision ?? "?"} was read; re-run the command (current: ${JSON.stringify(current?.data ?? null).slice(0, 1500)})`);
|
|
2147
|
+
}
|
|
2148
|
+
throw err;
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
1955
2151
|
const lifecycle = program
|
|
1956
2152
|
.command("lifecycle")
|
|
1957
2153
|
.description("Lifecycle settings: lead master statuses, intake-deal creation, how the tenant works leads, and lost propagation");
|
|
@@ -2007,7 +2203,7 @@ lifecycle
|
|
|
2007
2203
|
.action(run(async (client, mode) => {
|
|
2008
2204
|
if (mode !== "pipeline" && mode !== "status")
|
|
2009
2205
|
throw new Error("mode must be 'pipeline' or 'status'.");
|
|
2010
|
-
return client
|
|
2206
|
+
return patchGeneralSettings(client, { dealWorkingMode: mode });
|
|
2011
2207
|
}));
|
|
2012
2208
|
lifecycle
|
|
2013
2209
|
.command("set-lost-propagation")
|
|
@@ -2019,7 +2215,7 @@ lifecycle
|
|
|
2019
2215
|
throw new Error("Pass either --enable or --disable, not both.");
|
|
2020
2216
|
if (opts.enable === undefined && opts.disable === undefined)
|
|
2021
2217
|
throw new Error("Pass --enable or --disable.");
|
|
2022
|
-
return client
|
|
2218
|
+
return patchGeneralSettings(client, { lostPropagation: { enabled: Boolean(opts.enable) } });
|
|
2023
2219
|
}));
|
|
2024
2220
|
lifecycle
|
|
2025
2221
|
.command("set-intake-deals")
|
|
@@ -2051,7 +2247,7 @@ lifecycle
|
|
|
2051
2247
|
}
|
|
2052
2248
|
body.reInbound = opts.reInbound;
|
|
2053
2249
|
}
|
|
2054
|
-
return client
|
|
2250
|
+
return patchGeneralSettings(client, { intakeDeals: body });
|
|
2055
2251
|
}));
|
|
2056
2252
|
lifecycle
|
|
2057
2253
|
.command("set-master-statuses [statuses...]")
|
|
@@ -2075,7 +2271,7 @@ lifecycle
|
|
|
2075
2271
|
const bad = clean.find((s) => s.length > 40 || !/^[a-z0-9][a-z0-9 _-]*$/i.test(s));
|
|
2076
2272
|
if (bad)
|
|
2077
2273
|
throw new Error(`Invalid status name '${bad}' — 1-40 chars; letters, numbers, spaces, - and _ only.`);
|
|
2078
|
-
return client
|
|
2274
|
+
return patchGeneralSettings(client, { masterStatuses: clean });
|
|
2079
2275
|
}));
|
|
2080
2276
|
lifecycle
|
|
2081
2277
|
.command("set-disposition-status <disposition> <status>")
|
|
@@ -2096,7 +2292,7 @@ lifecycle
|
|
|
2096
2292
|
delete next[d];
|
|
2097
2293
|
else
|
|
2098
2294
|
next[d] = st;
|
|
2099
|
-
return client
|
|
2295
|
+
return patchGeneralSettings(client, { callDispositions: { statusMap: next } });
|
|
2100
2296
|
}));
|
|
2101
2297
|
// ---- account default columns for the Contacts list (tri-surface parity, 2026-09-03) ----
|
|
2102
2298
|
const CONTACT_COLUMN_IDS = ["name", "email", "phone", "company", "source", "tags", "score", "intent", "stage", "createdAt", "lastActivity", "assignedTo", "address", "city", "state", "zip"];
|
|
@@ -2124,7 +2320,10 @@ lifecycle
|
|
|
2124
2320
|
const bad = clean.find((id) => !CONTACT_COLUMN_IDS.includes(id) && !/^custom:[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}$/.test(id));
|
|
2125
2321
|
if (bad)
|
|
2126
2322
|
throw new Error(`Unknown column id '${bad}' — one of ${CONTACT_COLUMN_IDS.join(", ")} or custom:<fieldKey>.`);
|
|
2127
|
-
|
|
2323
|
+
// the defaults row carries its own revision token ("0" before the first save); the API refuses the write without it
|
|
2324
|
+
const current = (await client.get("/api/v1/settings"));
|
|
2325
|
+
const rev = current?.data?.contactTableDefaults?.revision;
|
|
2326
|
+
return client.patch("/api/v1/settings", { contactTableDefaults: { columns: clean, expectedRevision: typeof rev === "string" && rev ? rev : "0" } });
|
|
2128
2327
|
}));
|
|
2129
2328
|
// ---- call flows + ring groups (#25 tri-surface parity, 2026-08-23) ----
|
|
2130
2329
|
// IVR routing graphs and their companion ring sets. Settings-scoped at the
|
package/package.json
CHANGED