conduyt 1.67.0 → 1.68.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 +14 -1
- package/dist/calendly-status.js +87 -0
- package/dist/cli-harness.js +136 -0
- package/dist/client.js +107 -0
- package/dist/dialer-pages.js +102 -0
- package/dist/index.js +243 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,13 +78,26 @@ 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
|
+
conduyt messages check --channel sms --body "…" [--context first|conversation] [--provider twilio] [--clean] [--body-file draft.txt] # grade a DRAFT before sending (score, grade, flagged words with safer alternatives, Unicode code-point offsets); --provider twilio (texts only) words the notices in Twilio's wording, --clean adds the cleaner's proposal; educates, never blocks, nothing stored or sent; 20 per minute; 400 / 403 / 413 / 429 exit 1 with the code
|
|
82
|
+
conduyt sms providers options # the workspace's active SMS providers, redacted: [{ id, name, color }] (needs email_templates.view or settings.view)
|
|
83
|
+
conduyt templates list [--channel email|sms] [--active true] # email / SMS templates; each row carries bodyMode (blocks | html); the pasted html is not repeated in a list row
|
|
84
|
+
conduyt templates get <id> # one template with its full content (an HTML-mode template answers its pasted source in `html`)
|
|
85
|
+
conduyt templates create --name "Promo" --channel email --subject "Hi" --body-mode html --html-file email.html # pasted HTML (email only, 102,400 bytes at most); blocks: --body "…" [--body-html …]; 400 codes (html_too_large, html_script, html_conditional_comment, html_office_markup, html_duplicate_attribute, template_corrupt, …) exit 1 verbatim
|
|
86
|
+
conduyt templates update <id> [--name …] [--body-mode html --html-file email.html] [--active false] # partial update
|
|
87
|
+
conduyt dialer history --q "refund" --recorded-only [--disposition X] [--user <id>] [--from D] [--to D] [--mine] # QA search of the call log: per-row `match`, meta.incomplete (a lower-bound total when a number search is capped), stats; 200 per page
|
|
88
|
+
conduyt dialer calls --status voicemail --q "+1555…" # the recordings / voicemail list with the same search (100 per page)
|
|
89
|
+
conduyt dialer recording download <callId> [--voicemail] [-o file] [--force] # stream the call audio to a file (named by the API when no -o); never over an existing file without --force
|
|
82
90
|
conduyt privacy export <id> # GDPR data-portability export (owner/admin)
|
|
83
91
|
conduyt privacy forget <id> --confirm FORGET # GDPR erasure — IRREVERSIBLE, owner only
|
|
84
92
|
conduyt reply-capture status # inbound reply capture: state, DNS records, provider health
|
|
85
93
|
conduyt reply-capture setup # create the receiving subdomain (--domain optional)
|
|
86
94
|
conduyt reply-capture verify # promote it to live once DNS is confirmed
|
|
87
95
|
conduyt reply-capture remove --confirm REMOVE # remove capture — replies to already-sent email are lost
|
|
96
|
+
conduyt integrations calendly status # the Calendly connection: event types, selection, live sync (on | pending | plan_refused), import progress, last booking / error (integrations scope; connecting is browser-only)
|
|
97
|
+
conduyt integrations calendly import-progress [--connection-id <id>] # the import alone: status, eventsSeen, imported, skipped, error
|
|
98
|
+
conduyt integrations calendly event-types # re-read the event types from Calendly (the picker list)
|
|
99
|
+
conduyt integrations calendly set-event-types --uri https://api.calendly.com/event_types/<id> [--uri …] | --none # ONLY these sync; the first save or an added type starts the silent import
|
|
100
|
+
conduyt integrations calendly disconnect [--connection-id <id>] # removes the webhook and the connection; appointments stay
|
|
88
101
|
conduyt integrations wordpress-forms status # is any intake address active, and how many ({ hasActive, activeCount }; count only, read-only, integrations scope)
|
|
89
102
|
conduyt integrations wordpress-forms list # WordPress form intake addresses: full URL of every active one, received counts
|
|
90
103
|
conduyt integrations wordpress-forms create --name "Contact page" --default-tag "Website lead" # prints the full URL
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
2
|
+
const isInt = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
3
|
+
const strOrNull = (v) => v === null || typeof v === "string";
|
|
4
|
+
/** the import's states (the connection row: idle | queued | running | done | failed); done and failed are terminal */
|
|
5
|
+
export const CALENDLY_IMPORT_STATUSES = ["idle", "queued", "running", "done", "failed"];
|
|
6
|
+
export const CALENDLY_LIVE_SYNC = ["on", "pending", "plan_refused"];
|
|
7
|
+
function bad(path, why) {
|
|
8
|
+
throw new Error(`calendly status: the API answered something that is not the documented connection (${path}: ${why}); nothing was printed. This is a CLI/API version mismatch: update the CLI or report it.`);
|
|
9
|
+
}
|
|
10
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
const isDate = (v) => typeof v === "string" && /^\d{4}-\d{2}-\d{2}T/.test(v) && !Number.isNaN(Date.parse(v));
|
|
12
|
+
/** CalendlyEventType: { uri, name, active, kind, poolingType, schedulingUrl }, every key present */
|
|
13
|
+
function checkEventType(t, path) {
|
|
14
|
+
if (!isObj(t))
|
|
15
|
+
bad(path, "expected an object");
|
|
16
|
+
for (const k of ["uri", "name"])
|
|
17
|
+
if (typeof t[k] !== "string" || t[k] === "")
|
|
18
|
+
bad(`${path}.${k}`, "required string is missing");
|
|
19
|
+
if (typeof t.active !== "boolean")
|
|
20
|
+
bad(`${path}.active`, "expected a boolean");
|
|
21
|
+
for (const k of ["kind", "poolingType", "schedulingUrl"])
|
|
22
|
+
if (!(k in t) || !strOrNull(t[k]))
|
|
23
|
+
bad(`${path}.${k}`, "expected a string or null");
|
|
24
|
+
}
|
|
25
|
+
function checkConnection(c, path) {
|
|
26
|
+
if (!isObj(c))
|
|
27
|
+
bad(path, "expected an object");
|
|
28
|
+
for (const k of ["id", "userId"])
|
|
29
|
+
if (typeof c[k] !== "string" || !UUID.test(c[k]))
|
|
30
|
+
bad(`${path}.${k}`, "expected a UUID");
|
|
31
|
+
if (typeof c.status !== "string" || c.status === "")
|
|
32
|
+
bad(`${path}.status`, "required string is missing");
|
|
33
|
+
if (!isDate(c.createdAt))
|
|
34
|
+
bad(`${path}.createdAt`, "expected an ISO date-time");
|
|
35
|
+
for (const k of ["calendlyEmail", "calendlyName", "planWarning", "lastError"])
|
|
36
|
+
if (!(k in c) || !strOrNull(c[k]))
|
|
37
|
+
bad(`${path}.${k}`, "expected a string or null");
|
|
38
|
+
for (const k of ["lastBookingAt", "lastErrorAt"])
|
|
39
|
+
if (!(k in c) || (c[k] !== null && !isDate(c[k])))
|
|
40
|
+
bad(`${path}.${k}`, "expected an ISO date-time or null");
|
|
41
|
+
if (!Array.isArray(c.eventTypes))
|
|
42
|
+
bad(`${path}.eventTypes`, "expected an array");
|
|
43
|
+
c.eventTypes.forEach((t, i) => checkEventType(t, `${path}.eventTypes[${i}]`));
|
|
44
|
+
if (!Array.isArray(c.selectedEventTypeUris) || !c.selectedEventTypeUris.every((u) => typeof u === "string"))
|
|
45
|
+
bad(`${path}.selectedEventTypeUris`, "expected an array of strings");
|
|
46
|
+
if (typeof c.selectionSaved !== "boolean")
|
|
47
|
+
bad(`${path}.selectionSaved`, "expected a boolean");
|
|
48
|
+
if (!CALENDLY_LIVE_SYNC.includes(c.liveSync))
|
|
49
|
+
bad(`${path}.liveSync`, `expected one of ${CALENDLY_LIVE_SYNC.join(", ")}`);
|
|
50
|
+
const imp = c.import;
|
|
51
|
+
if (!isObj(imp))
|
|
52
|
+
bad(`${path}.import`, "expected an object");
|
|
53
|
+
if (!CALENDLY_IMPORT_STATUSES.includes(imp.status))
|
|
54
|
+
bad(`${path}.import.status`, `expected one of ${CALENDLY_IMPORT_STATUSES.join(", ")}`);
|
|
55
|
+
for (const k of ["eventsSeen", "imported", "skipped"])
|
|
56
|
+
if (!isInt(imp[k]))
|
|
57
|
+
bad(`${path}.import.${k}`, "expected a non-negative integer");
|
|
58
|
+
for (const k of ["startedAt", "finishedAt"])
|
|
59
|
+
if (!(k in imp) || (imp[k] !== null && !isDate(imp[k])))
|
|
60
|
+
bad(`${path}.import.${k}`, "expected an ISO date-time or null");
|
|
61
|
+
if (!("error" in imp) || !strOrNull(imp.error))
|
|
62
|
+
bad(`${path}.import.error`, "expected a string or null");
|
|
63
|
+
return c;
|
|
64
|
+
}
|
|
65
|
+
/** validate the whole status answer; returns the envelope's data */
|
|
66
|
+
export function calendlyStatus(res) {
|
|
67
|
+
if (!isObj(res) || !isObj(res.data))
|
|
68
|
+
bad("$", "expected { data: { ... } }");
|
|
69
|
+
const d = res.data;
|
|
70
|
+
if (typeof d.configured !== "boolean")
|
|
71
|
+
bad("configured", "expected a boolean");
|
|
72
|
+
if (typeof d.connected !== "boolean")
|
|
73
|
+
bad("connected", "expected a boolean");
|
|
74
|
+
for (const k of ["description", "importLine"])
|
|
75
|
+
if (typeof d[k] !== "string")
|
|
76
|
+
bad(k, "expected a string");
|
|
77
|
+
const connection = d.connection === null ? null : checkConnection(d.connection, "connection");
|
|
78
|
+
if (d.connected !== (connection !== null))
|
|
79
|
+
bad("connected", "disagrees with connection");
|
|
80
|
+
let connections;
|
|
81
|
+
if (d.connections !== undefined) {
|
|
82
|
+
if (!Array.isArray(d.connections))
|
|
83
|
+
bad("connections", "expected an array");
|
|
84
|
+
connections = d.connections.map((c, i) => checkConnection(c, `connections[${i}]`));
|
|
85
|
+
}
|
|
86
|
+
return { ...d, connection, connections };
|
|
87
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** Test helper (not a test): run the real CLI against a local HTTP server, as the other command tests do. */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
const entry = join(dirname(fileURLToPath(import.meta.url)), "index.ts");
|
|
7
|
+
/** The handshake never passes silently: a predicate that throws or never becomes true is recorded here and withServer fails the test with it. */
|
|
8
|
+
export async function withServer(reply, fn) {
|
|
9
|
+
const seen = [];
|
|
10
|
+
const failures = [];
|
|
11
|
+
const server = createServer((req, res) => {
|
|
12
|
+
let body = "";
|
|
13
|
+
req.on("data", (c) => (body += c));
|
|
14
|
+
req.on("end", () => {
|
|
15
|
+
const s = { method: req.method, url: req.url, body, headers: req.headers };
|
|
16
|
+
seen.push(s);
|
|
17
|
+
const r = reply(s, seen.length);
|
|
18
|
+
res.statusCode = r.status;
|
|
19
|
+
if (r.raw === undefined)
|
|
20
|
+
res.setHeader("content-type", "application/json");
|
|
21
|
+
for (const [k, v] of Object.entries(r.headers ?? {}))
|
|
22
|
+
res.setHeader(k, v);
|
|
23
|
+
if (r.hold) {
|
|
24
|
+
res.setHeader("content-length", String(Buffer.byteLength(r.raw ?? "") + 1000));
|
|
25
|
+
res.write(r.raw ?? "");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (r.abort) {
|
|
29
|
+
res.setHeader("content-length", String(Buffer.byteLength(r.raw ?? "") + 1000));
|
|
30
|
+
res.write(r.raw ?? "");
|
|
31
|
+
const give = Date.now() + (r.abortWithin ?? 30000);
|
|
32
|
+
const cut = () => {
|
|
33
|
+
let ready = false;
|
|
34
|
+
if (!r.abortWhen)
|
|
35
|
+
ready = true;
|
|
36
|
+
else {
|
|
37
|
+
try {
|
|
38
|
+
ready = r.abortWhen();
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
failures.push(`abortWhen threw before the partial file was observed: ${e instanceof Error ? e.message : String(e)}`);
|
|
42
|
+
res.socket?.destroy();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (ready)
|
|
47
|
+
res.socket?.destroy();
|
|
48
|
+
else if (Date.now() > give) {
|
|
49
|
+
failures.push(`abortWhen never became true within ${r.abortWithin ?? 30000} ms: the stream was cut without the precondition (a non-empty partial file) being observed`);
|
|
50
|
+
res.socket?.destroy();
|
|
51
|
+
}
|
|
52
|
+
else
|
|
53
|
+
setTimeout(cut, 20);
|
|
54
|
+
};
|
|
55
|
+
setTimeout(cut, r.abortWhen ? 20 : 50);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
res.end(r.raw !== undefined ? r.raw : JSON.stringify(r.json));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
const serverFailed = new Promise((_, reject) => server.on("error", (e) => reject(new Error(`test server error: ${e instanceof Error ? e.message : String(e)}`))));
|
|
62
|
+
serverFailed.catch(() => undefined);
|
|
63
|
+
await Promise.race([new Promise((r) => server.listen(0, "127.0.0.1", r)), serverFailed]);
|
|
64
|
+
try {
|
|
65
|
+
await Promise.race([fn(`http://127.0.0.1:${server.address().port}`, seen), serverFailed]);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
// held-open responses must not keep the run alive: drop every connection, then wait for the server to be gone
|
|
69
|
+
server.closeAllConnections();
|
|
70
|
+
await new Promise((r) => server.close(() => r()));
|
|
71
|
+
}
|
|
72
|
+
if (failures.length)
|
|
73
|
+
throw new Error(`test handshake failed: ${failures.join("; ")}`);
|
|
74
|
+
}
|
|
75
|
+
/** The promise of a child's `close` (stdio finished, which can lag the process's exit); a spawn failure rejects with an attributed error. */
|
|
76
|
+
export function closeOf(p, getErr = () => "") {
|
|
77
|
+
const done = new Promise((resolve, reject) => {
|
|
78
|
+
p.on("error", (e) => reject(new Error(`could not run the CLI child: ${e.message}`)));
|
|
79
|
+
p.on("close", (code, signal) => resolve({ code, signal, err: getErr() }));
|
|
80
|
+
});
|
|
81
|
+
done.catch(() => undefined);
|
|
82
|
+
return done;
|
|
83
|
+
}
|
|
84
|
+
/** start the CLI and hand back the child (to signal it) and its result */
|
|
85
|
+
export function startCli(url, args, cwd) {
|
|
86
|
+
const p = spawn(process.execPath, ["--import", import.meta.resolve("tsx"), entry, ...args], {
|
|
87
|
+
env: { ...process.env, CONDUYT_API_URL: url, CONDUYT_API_KEY: "cdy_dummy_test_key" },
|
|
88
|
+
...(cwd ? { cwd } : {}),
|
|
89
|
+
});
|
|
90
|
+
let err = "";
|
|
91
|
+
p.stderr.on("data", (c) => (err += c));
|
|
92
|
+
return { p, done: closeOf(p, () => err) };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Wait for a child to end within `ms`. `close` can lag the process's `exit`, and signalling an exited child can hit a reused PID, so
|
|
96
|
+
* after the deadline the child's exit state is re-checked: SIGKILL goes out ONLY if no exit was observed; if it had exited, the
|
|
97
|
+
* remaining wait is for its stdio to close (bounded, no further signal). A regression cannot hang the suite.
|
|
98
|
+
*/
|
|
99
|
+
export async function boundedExit(p, done, ms = 20000, closeMs = 10000) {
|
|
100
|
+
const wait = (t) => new Promise((r) => setTimeout(() => r("timeout"), t).unref());
|
|
101
|
+
const first = await Promise.race([done, wait(ms)]);
|
|
102
|
+
if (first !== "timeout")
|
|
103
|
+
return first;
|
|
104
|
+
const exited = p.exitCode !== null || p.signalCode !== null;
|
|
105
|
+
if (!exited)
|
|
106
|
+
p.kill("SIGKILL");
|
|
107
|
+
const second = await Promise.race([done, wait(closeMs)]);
|
|
108
|
+
if (second === "timeout")
|
|
109
|
+
throw new Error(exited ? `the CLI child exited but its stdio did not close within ${closeMs} ms (no signal was sent)` : `the CLI child did not exit within ${ms} ms and survived SIGKILL for ${closeMs} ms`);
|
|
110
|
+
if (exited)
|
|
111
|
+
return second; // it had already exited: only the close was late
|
|
112
|
+
throw new Error(`the CLI child did not exit within ${ms} ms after the signal; it was killed with SIGKILL (exit ${second.code}, signal ${second.signal}): ${second.err}`);
|
|
113
|
+
}
|
|
114
|
+
export function runCli(url, args, cwd) {
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
const p = spawn(process.execPath, ["--import", import.meta.resolve("tsx"), entry, ...args], {
|
|
117
|
+
env: { ...process.env, CONDUYT_API_URL: url, CONDUYT_API_KEY: "cdy_dummy_test_key" },
|
|
118
|
+
...(cwd ? { cwd } : {}),
|
|
119
|
+
});
|
|
120
|
+
let out = "", err = "";
|
|
121
|
+
p.stdout.on("data", (c) => (out += c));
|
|
122
|
+
p.stderr.on("data", (c) => (err += c));
|
|
123
|
+
p.on("error", (e) => resolve({ status: null, out, err: `${err}could not run the CLI child: ${e.message}` }));
|
|
124
|
+
p.on("close", (status) => resolve({ status, out, err }));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** the final token of an error text is ONE valid JSON object */
|
|
128
|
+
export function finalDetails(text) {
|
|
129
|
+
for (let i = text.indexOf("{"); i !== -1; i = text.indexOf("{", i + 1)) {
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(text.slice(i).trim());
|
|
132
|
+
}
|
|
133
|
+
catch { /* next brace */ }
|
|
134
|
+
}
|
|
135
|
+
throw new Error(`no trailing JSON object in: ${text}`);
|
|
136
|
+
}
|
package/dist/client.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createWriteStream, existsSync, linkSync, renameSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { open } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
1
7
|
import { requireAuth } from "./config.js";
|
|
2
8
|
/** A deadline hit (only when the call set one) is "the server may have applied it", for every method: read the state first. */
|
|
3
9
|
function ambiguousTimeout(err, method, path, timeoutMs, what) {
|
|
@@ -171,6 +177,74 @@ export class ConduytClient {
|
|
|
171
177
|
throw formatHttpError(res, text);
|
|
172
178
|
return { text, filename: res.headers.get("x-report-filename"), contentType: res.headers.get("content-type") };
|
|
173
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* GET a binary body (call audio) and stream it to a file: nothing is held in memory. `outFile` null = name it from the response's
|
|
182
|
+
* Content-Disposition (the basename only), else `fallbackName`. The bytes go to a unique temporary file in the destination folder,
|
|
183
|
+
* are flushed to disk, and only then committed to the final name in one step: without `overwrite` the commit refuses (EEXIST) a file
|
|
184
|
+
* that appeared meanwhile and never replaces it; with `overwrite` the old file is replaced only after the whole download arrived.
|
|
185
|
+
* A refusal (any non-2xx) or a broken stream writes nothing at the final name and leaves no temporary file behind.
|
|
186
|
+
*/
|
|
187
|
+
async download(path, opts) {
|
|
188
|
+
const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
|
189
|
+
// a fast refusal before any bytes move; the commit below is the authority (a file can appear while the download runs)
|
|
190
|
+
if (opts.outFile !== null && !opts.overwrite && existsSync(opts.outFile))
|
|
191
|
+
throw new Error(`${opts.outFile} already exists; pass -o <another file> or --force to overwrite it.`);
|
|
192
|
+
const res = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${this.apiKey}` } });
|
|
193
|
+
if (!res.ok)
|
|
194
|
+
throw formatHttpError(res, await res.text());
|
|
195
|
+
if (!res.body)
|
|
196
|
+
throw new Error("Conduyt API: the response had no body.");
|
|
197
|
+
const target = opts.outFile ?? dispositionFilename(res.headers.get("content-disposition")) ?? opts.fallbackName;
|
|
198
|
+
if (!opts.overwrite && existsSync(target)) {
|
|
199
|
+
await res.body.cancel().catch(() => undefined);
|
|
200
|
+
throw new Error(`${target} already exists; pass -o <another file> or --force to overwrite it.`);
|
|
201
|
+
}
|
|
202
|
+
const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.${randomBytes(6).toString("hex")}.part`);
|
|
203
|
+
let bytes = 0;
|
|
204
|
+
// An interrupt (Ctrl-C, kill) does not unwind the catch below: while the temporary file exists, SIGINT / SIGTERM remove it first, put the
|
|
205
|
+
// handlers back, and re-raise the signal so the process ends exactly as it would have (exit status 128 + signal). Partial call audio is
|
|
206
|
+
// sensitive and must not survive.
|
|
207
|
+
const onSignal = (sig) => {
|
|
208
|
+
try {
|
|
209
|
+
unlinkSync(tmp);
|
|
210
|
+
}
|
|
211
|
+
catch { /* not created yet, or already gone */ }
|
|
212
|
+
process.removeListener("SIGINT", onInt);
|
|
213
|
+
process.removeListener("SIGTERM", onTerm);
|
|
214
|
+
process.kill(process.pid, sig);
|
|
215
|
+
};
|
|
216
|
+
const onInt = () => onSignal("SIGINT");
|
|
217
|
+
const onTerm = () => onSignal("SIGTERM");
|
|
218
|
+
process.on("SIGINT", onInt);
|
|
219
|
+
process.on("SIGTERM", onTerm);
|
|
220
|
+
try {
|
|
221
|
+
const counted = Readable.fromWeb(res.body);
|
|
222
|
+
counted.on("data", (c) => { bytes += c.length; });
|
|
223
|
+
await pipeline(counted, createWriteStream(tmp, { flags: "wx", mode: 0o600 }));
|
|
224
|
+
const fh = await open(tmp, "r+");
|
|
225
|
+
try {
|
|
226
|
+
await fh.sync();
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
await fh.close();
|
|
230
|
+
}
|
|
231
|
+
commitDownload(tmp, target, Boolean(opts.overwrite));
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
try {
|
|
235
|
+
unlinkSync(tmp);
|
|
236
|
+
}
|
|
237
|
+
catch { /* no temporary file left to remove */ }
|
|
238
|
+
if (err instanceof Error && err.code === "EEXIST")
|
|
239
|
+
throw new Error(`${target} already exists; pass -o <another file> or --force to overwrite it. Nothing was written.`);
|
|
240
|
+
throw new Error(`Download failed before the file was complete (${err instanceof Error ? err.message : String(err)}); nothing was kept.`);
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
process.removeListener("SIGINT", onInt);
|
|
244
|
+
process.removeListener("SIGTERM", onTerm);
|
|
245
|
+
}
|
|
246
|
+
return { saved: target, bytes, contentType: res.headers.get("content-type") };
|
|
247
|
+
}
|
|
174
248
|
// Stream `ai chat`'s text/event-stream response to stdout as clean answer
|
|
175
249
|
// text. The server frames SSE records as `data: {"text":"..."}\n\n`, ends
|
|
176
250
|
// with `data: [DONE]\n\n`, and reports in-stream failures as
|
|
@@ -248,6 +322,39 @@ export class ConduytClient {
|
|
|
248
322
|
process.stdout.write("\n");
|
|
249
323
|
}
|
|
250
324
|
}
|
|
325
|
+
/** The file name a Content-Disposition names (RFC 5987 filename* wins over filename), reduced to a basename so a header can never pick a directory. */
|
|
326
|
+
export function dispositionFilename(header) {
|
|
327
|
+
if (!header)
|
|
328
|
+
return null;
|
|
329
|
+
let name = null;
|
|
330
|
+
const star = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header);
|
|
331
|
+
if (star) {
|
|
332
|
+
try {
|
|
333
|
+
name = decodeURIComponent(star[1].trim());
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
name = null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (name === null) {
|
|
340
|
+
const plain = /filename\s*=\s*"((?:[^"\\]|\\.)*)"|filename\s*=\s*([^;]+)/i.exec(header);
|
|
341
|
+
if (plain)
|
|
342
|
+
name = (plain[1] ?? plain[2] ?? "").trim().replace(/\\(.)/g, "$1");
|
|
343
|
+
}
|
|
344
|
+
if (!name)
|
|
345
|
+
return null;
|
|
346
|
+
const base = basename(name.replace(/\\/g, "/"));
|
|
347
|
+
return base && base !== "." && base !== ".." ? base : null;
|
|
348
|
+
}
|
|
349
|
+
/** Move a finished temporary file onto its final name. No-clobber: a hard link fails with EEXIST when the name is taken, so an existing file is never touched; with `overwrite` the rename replaces it atomically. */
|
|
350
|
+
export function commitDownload(tmp, target, overwrite) {
|
|
351
|
+
if (overwrite) {
|
|
352
|
+
renameSync(tmp, target);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
linkSync(tmp, target);
|
|
356
|
+
unlinkSync(tmp);
|
|
357
|
+
}
|
|
251
358
|
function safeParse(text) {
|
|
252
359
|
try {
|
|
253
360
|
return JSON.parse(text);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
2
|
+
const isInt = (v) => typeof v === "number" && Number.isInteger(v);
|
|
3
|
+
function bad(what, path, why) {
|
|
4
|
+
throw new Error(`${what}: the API answered something that is not the documented page (${path}: ${why}); nothing was printed. This is a CLI/API version mismatch: update the CLI or report it.`);
|
|
5
|
+
}
|
|
6
|
+
/** a field that may be absent: when present it must be one of the kinds */
|
|
7
|
+
function optional(o, key, ok, kind, what, path) {
|
|
8
|
+
if (key in o && o[key] !== undefined && !ok(o[key]))
|
|
9
|
+
bad(what, `${path}.${key}`, `expected ${kind}`);
|
|
10
|
+
}
|
|
11
|
+
const str = (v) => typeof v === "string";
|
|
12
|
+
const strOrNull = (v) => v === null || typeof v === "string";
|
|
13
|
+
const intOrNull = (v) => v === null || isInt(v);
|
|
14
|
+
const MATCH_FIELDS = ["name", "number", "agent", "transcript"];
|
|
15
|
+
function checkMatch(m, what, path) {
|
|
16
|
+
if (m === null)
|
|
17
|
+
return;
|
|
18
|
+
if (!isObj(m))
|
|
19
|
+
bad(what, path, "expected an object or null");
|
|
20
|
+
if (typeof m.field !== "string" || !MATCH_FIELDS.includes(m.field))
|
|
21
|
+
bad(what, `${path}.field`, `expected one of ${MATCH_FIELDS.join(", ")}`);
|
|
22
|
+
optional(m, "snippet", str, "a string", what, path);
|
|
23
|
+
if ("ranges" in m && m.ranges !== undefined) {
|
|
24
|
+
if (!Array.isArray(m.ranges))
|
|
25
|
+
bad(what, `${path}.ranges`, "expected an array");
|
|
26
|
+
m.ranges.forEach((r, i) => {
|
|
27
|
+
if (!Array.isArray(r) || r.length !== 2 || !isInt(r[0]) || !isInt(r[1]) || r[0] < 0 || r[1] < 0)
|
|
28
|
+
bad(what, `${path}.ranges[${i}]`, "expected [start, end] non-negative integers");
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function checkRow(row, what, path) {
|
|
33
|
+
if (!isObj(row))
|
|
34
|
+
bad(what, path, "expected an object");
|
|
35
|
+
for (const k of ["id", "direction", "fromNumber", "toNumber", "status", "createdAt"])
|
|
36
|
+
if (!str(row[k]))
|
|
37
|
+
bad(what, `${path}.${k}`, "required string is missing");
|
|
38
|
+
for (const k of ["hasRecording", "hasVoicemail"])
|
|
39
|
+
if (typeof row[k] !== "boolean")
|
|
40
|
+
bad(what, `${path}.${k}`, "required boolean is missing");
|
|
41
|
+
for (const k of ["contactId", "userId", "notes", "voicemailReadAt", "voicemailDeletedAt", "transcriptionText", "transcriptionUrl", "disposition", "destinationCallSid", "transferCallSid", "transferStatus", "transferTargetNumber", "conferenceSid", "startedAt", "endedAt"]) {
|
|
42
|
+
optional(row, k, strOrNull, "a string or null", what, path);
|
|
43
|
+
}
|
|
44
|
+
optional(row, "duration", intOrNull, "an integer or null", what, path);
|
|
45
|
+
optional(row, "dncOverride", (v) => typeof v === "boolean", "a boolean", what, path);
|
|
46
|
+
for (const k of ["contact", "user"])
|
|
47
|
+
optional(row, k, (v) => v === null || isObj(v), "an object or null", what, path);
|
|
48
|
+
if ("match" in row && row.match !== undefined)
|
|
49
|
+
checkMatch(row.match, what, `${path}.match`);
|
|
50
|
+
}
|
|
51
|
+
function checkRows(rows, what, path) {
|
|
52
|
+
if (!Array.isArray(rows))
|
|
53
|
+
bad(what, path, "expected an array");
|
|
54
|
+
rows.forEach((r, i) => checkRow(r, what, `${path}[${i}]`));
|
|
55
|
+
}
|
|
56
|
+
function checkIncomplete(meta, what) {
|
|
57
|
+
optional(meta, "incomplete", (v) => typeof v === "boolean", "a boolean", what, "meta");
|
|
58
|
+
optional(meta, "incompleteReason", (v) => v === "number_matches_capped", '"number_matches_capped"', what, "meta");
|
|
59
|
+
const has = (k) => k in meta && meta[k] !== undefined;
|
|
60
|
+
if (has("incompleteReason") && meta.incomplete !== true)
|
|
61
|
+
bad(what, "meta.incompleteReason", "present without incomplete: true");
|
|
62
|
+
if (meta.incomplete === true && !has("incompleteReason"))
|
|
63
|
+
bad(what, "meta.incompleteReason", "missing with incomplete: true");
|
|
64
|
+
}
|
|
65
|
+
/** GET /dialer/history: returns the page { data, meta, stats } */
|
|
66
|
+
export function historyPage(res) {
|
|
67
|
+
const what = "dialer history";
|
|
68
|
+
if (!isObj(res) || !isObj(res.data))
|
|
69
|
+
bad(what, "$", "expected { data: { data, meta, stats } }");
|
|
70
|
+
const page = res.data;
|
|
71
|
+
checkRows(page.data, what, "data");
|
|
72
|
+
const meta = page.meta;
|
|
73
|
+
if (!isObj(meta))
|
|
74
|
+
bad(what, "meta", "expected an object");
|
|
75
|
+
for (const k of ["page", "per_page", "total"])
|
|
76
|
+
if (!isInt(meta[k]))
|
|
77
|
+
bad(what, `meta.${k}`, "required integer is missing");
|
|
78
|
+
checkIncomplete(meta, what);
|
|
79
|
+
const stats = page.stats;
|
|
80
|
+
if (!isObj(stats))
|
|
81
|
+
bad(what, "stats", "expected an object");
|
|
82
|
+
for (const k of ["total", "inbound", "outbound"])
|
|
83
|
+
if (!isInt(stats[k]))
|
|
84
|
+
bad(what, `stats.${k}`, "required integer is missing");
|
|
85
|
+
optional(stats, "avgTalkSeconds", isInt, "an integer", what, "stats");
|
|
86
|
+
return page;
|
|
87
|
+
}
|
|
88
|
+
/** GET /dialer/calls: returns the page { data, meta } */
|
|
89
|
+
export function callsPage(res) {
|
|
90
|
+
const what = "dialer calls";
|
|
91
|
+
if (!isObj(res))
|
|
92
|
+
bad(what, "$", "expected { data, meta }");
|
|
93
|
+
checkRows(res.data, what, "data");
|
|
94
|
+
const meta = res.meta;
|
|
95
|
+
if (!isObj(meta))
|
|
96
|
+
bad(what, "meta", "expected an object");
|
|
97
|
+
for (const k of ["page", "perPage", "total", "totalPages"])
|
|
98
|
+
if (!isInt(meta[k]))
|
|
99
|
+
bad(what, `meta.${k}`, "required integer is missing");
|
|
100
|
+
checkIncomplete(meta, what);
|
|
101
|
+
return res;
|
|
102
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,8 @@ import { splitList, stageRulePatch, validateCardFields, validateRuleChange } fro
|
|
|
9
9
|
import { ARCHIVE_PIPELINE_TIMEOUT_MS, MAILCHIMP_ADMIN_TIMEOUT_MS, STATUS_METADATA_IMPORT_TIMEOUT_MS, PIPELINE_COLORS, boolFlag, intFlag, isoDate, lifecycleStage as lifecycleStageArg, MAX_PIPELINE_ROT_DAYS, MIN_PIPELINE_ROT_DAYS, pipelineColor, settingsRevision, slug as slugArg, uuid as uuidOf, uuidList, validateImportItems, validateStatusMetadataBody } from "./pipeline-settings.js";
|
|
10
10
|
import { saveConfig, resolveConfig, configPath } from "./config.js";
|
|
11
11
|
import { print, fail, unwrap } from "./output.js";
|
|
12
|
+
import { callsPage, historyPage } from "./dialer-pages.js";
|
|
13
|
+
import { calendlyStatus } from "./calendly-status.js";
|
|
12
14
|
/** the contact save revision bound (catalog: integer 0..2147483647) */
|
|
13
15
|
const CONTACT_REVISION_MAX = 2147483647;
|
|
14
16
|
// Exit cleanly when a downstream pipe closes early (e.g. `conduyt deals list |
|
|
@@ -1845,13 +1847,15 @@ messages
|
|
|
1845
1847
|
}));
|
|
1846
1848
|
messages
|
|
1847
1849
|
.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 (
|
|
1850
|
+
.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 (SMS; informational), breakdown (the score's bands, offsets in code points), unicodeCulprits (SMS; the characters that force Unicode), cleaned (only with --clean), engine { version, updatedAt } } (engine 2.1.2 today; any further field is additive). 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. --provider twilio (a text only) words the notices in Twilio's own wording, as the composers show them; an email with a provider, or any other value, is a 400 invalid_body (`sms providers options` lists the workspace's providers; only twilio is a check wording today). --clean adds `cleaned` { text, subject (email), changes, skipped } or null: the cleaner's PROPOSAL, never stored or applied. 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
1851
|
.option("--channel <channel>", "sms | email (required)")
|
|
1850
1852
|
.option("--subject <text>", "email only (300 characters at most); refused on a text")
|
|
1851
1853
|
.option("--body <text>", "the draft text (10,000 characters at most)")
|
|
1852
1854
|
.option("--body-file <path>", "read the draft text from a file (for long text; not with --body)")
|
|
1853
1855
|
.option("--context <context>", "template (default) | first | conversation")
|
|
1854
|
-
.option("--
|
|
1856
|
+
.option("--provider <provider>", "twilio (a text only): word the notices in Twilio's wording; an email with a provider is a 400")
|
|
1857
|
+
.option("--clean", "add `cleaned`: the cleaner's proposal for the text (never stored or applied)")
|
|
1858
|
+
.option("--json <json>", "full JSON body { channel, subject?, body, context?, provider?, clean? }, merged over the flags")
|
|
1855
1859
|
.action(run(async (client, opts) => {
|
|
1856
1860
|
if (opts.body !== undefined && opts.bodyFile !== undefined)
|
|
1857
1861
|
throw new Error("Pass either --body or --body-file, not both.");
|
|
@@ -1874,6 +1878,10 @@ messages
|
|
|
1874
1878
|
}
|
|
1875
1879
|
if (opts.context !== undefined)
|
|
1876
1880
|
body.context = opts.context;
|
|
1881
|
+
if (opts.provider !== undefined)
|
|
1882
|
+
body.provider = opts.provider;
|
|
1883
|
+
if (opts.clean)
|
|
1884
|
+
body.clean = true;
|
|
1877
1885
|
if (opts.json !== undefined)
|
|
1878
1886
|
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
1879
1887
|
if (body.channel === undefined)
|
|
@@ -1882,6 +1890,109 @@ messages
|
|
|
1882
1890
|
throw new Error("The draft is required. Pass --body '<text>' or --body-file <path>.");
|
|
1883
1891
|
return client.post("/api/v1/messages/check", body);
|
|
1884
1892
|
}));
|
|
1893
|
+
// ---- email / SMS templates (pasted-HTML email templates, #127, 2026-10-09) ----
|
|
1894
|
+
const templates = program.command("templates").description("Email and SMS templates (API key scope emails / templates; a member: email_templates.view / create / edit). Email templates are built in blocks or pasted as HTML (`--body-mode html --html-file page.html`)");
|
|
1895
|
+
const TEMPLATE_REFUSALS = "Every refusal exits 1 with the status, the API's message and its code, sent once and never retried: 400 invalid_body_mode (--body-mode is not blocks / html), html_without_html_mode (html needs --body-mode html), html_required (html mode with no html), html_too_large (html over 102,400 bytes), html_script (a <script> in the html), html_conditional_comment (an Outlook <!--[if ...]--> conditional comment), html_office_markup (Word / Office markup), html_duplicate_attribute (an element repeating an attribute), template_corrupt (an unreadable or doubled builder marker in bodyHtml), body_html_required (blocks mode with no --body-html), body_mode_conflict, template_meta_conflict";
|
|
1896
|
+
function templateBodyFlags(cmd) {
|
|
1897
|
+
return cmd
|
|
1898
|
+
.option("--subject <text>", "email subject line")
|
|
1899
|
+
.option("--body <text>", "template body (plain text for SMS, text/HTML for email)")
|
|
1900
|
+
.option("--body-file <path>", "read the body from a file (not with --body)")
|
|
1901
|
+
.option("--body-html <html>", "email HTML body (sanitized server-side); with --body-mode blocks it is the builder's HTML")
|
|
1902
|
+
.option("--body-mode <mode>", "blocks (default) | html (pasted HTML, email only; send --html or --html-file)")
|
|
1903
|
+
.option("--html <html>", "the pasted email HTML (with --body-mode html; 102,400 bytes at most)")
|
|
1904
|
+
.option("--html-file <path>", "read the pasted email HTML from a file (not with --html)")
|
|
1905
|
+
.option("--category <category>", "category")
|
|
1906
|
+
.option("--json <json>", "full JSON body, merged over the flags");
|
|
1907
|
+
}
|
|
1908
|
+
function readTextFile(path, flag) {
|
|
1909
|
+
if (path.trim() === "")
|
|
1910
|
+
throw new Error(`${flag} must be a path (got an empty string).`);
|
|
1911
|
+
try {
|
|
1912
|
+
return readFileSync(path, "utf8");
|
|
1913
|
+
}
|
|
1914
|
+
catch {
|
|
1915
|
+
throw new Error(`${flag} could not be read: ${path}`);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
/** the fields a template create / update sends: only what was given, nothing checked locally beyond the file reads (the API refuses with its codes) */
|
|
1919
|
+
function templateBody(opts) {
|
|
1920
|
+
if (opts.body !== undefined && opts.bodyFile !== undefined)
|
|
1921
|
+
throw new Error("Pass either --body or --body-file, not both.");
|
|
1922
|
+
if (opts.html !== undefined && opts.htmlFile !== undefined)
|
|
1923
|
+
throw new Error("Pass either --html or --html-file, not both.");
|
|
1924
|
+
const body = {};
|
|
1925
|
+
if (opts.subject !== undefined)
|
|
1926
|
+
body.subject = opts.subject;
|
|
1927
|
+
if (opts.body !== undefined)
|
|
1928
|
+
body.body = opts.body;
|
|
1929
|
+
if (opts.bodyFile !== undefined)
|
|
1930
|
+
body.body = readTextFile(opts.bodyFile, "--body-file");
|
|
1931
|
+
if (opts.bodyHtml !== undefined)
|
|
1932
|
+
body.bodyHtml = opts.bodyHtml;
|
|
1933
|
+
if (opts.bodyMode !== undefined)
|
|
1934
|
+
body.bodyMode = opts.bodyMode;
|
|
1935
|
+
if (opts.html !== undefined)
|
|
1936
|
+
body.html = opts.html;
|
|
1937
|
+
if (opts.htmlFile !== undefined)
|
|
1938
|
+
body.html = readTextFile(opts.htmlFile, "--html-file");
|
|
1939
|
+
if (opts.category !== undefined)
|
|
1940
|
+
body.category = opts.category;
|
|
1941
|
+
if (opts.json !== undefined)
|
|
1942
|
+
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
1943
|
+
return body;
|
|
1944
|
+
}
|
|
1945
|
+
templates
|
|
1946
|
+
.command("list")
|
|
1947
|
+
.description("List email and SMS templates (GET /emails/templates). Each row carries `bodyMode` (blocks = built in the block builder, html = pasted HTML); the pasted `html` itself is NOT repeated in a list row (read it with `templates get`). Prints { data, meta }")
|
|
1948
|
+
.option("--channel <channel>", "sms | email")
|
|
1949
|
+
.option("--category <category>", "category")
|
|
1950
|
+
.option("--active <bool>", "true | false")
|
|
1951
|
+
.option("--page <n>", "page number")
|
|
1952
|
+
.option("--limit <n>", "results per page (per_page, default 50, max 200)")
|
|
1953
|
+
.action(run(async (client, opts) => ({
|
|
1954
|
+
data: await client.get(`/api/v1/emails/templates${buildQuery({ channel: opts.channel, category: opts.category, is_active: opts.active === undefined ? undefined : String(boolFlag(opts.active, "--active")), page: opts.page, per_page: opts.limit })}`),
|
|
1955
|
+
})));
|
|
1956
|
+
templates
|
|
1957
|
+
.command("get <id>")
|
|
1958
|
+
.description("Get one template with its full content (GET /emails/templates/{id}). An HTML-mode email template (`bodyMode: html`) answers its pasted source in `html`")
|
|
1959
|
+
.action(run(async (client, id) => { assertUuid(id, "template id"); return client.get(`/api/v1/emails/templates/${encodeURIComponent(id.trim())}`); }));
|
|
1960
|
+
templateBodyFlags(templates
|
|
1961
|
+
.command("create")
|
|
1962
|
+
.description(`Create an email or SMS template (POST /emails/templates). Two shapes: BLOCKS (default; --body required, an email may add --body-html; --body-mode blocks REQUIRES --body-html) or PASTED HTML (email only: --body-mode html with --html / --html-file, 102,400 bytes at most; --body is then optional, the plain-text part defaults to the HTML's visible text). ${TEMPLATE_REFUSALS}`)
|
|
1963
|
+
.requiredOption("--name <name>", "template name")
|
|
1964
|
+
.option("--channel <channel>", "sms | email (required)")
|
|
1965
|
+
.option("--inactive", "create it inactive (default active)")).action(run(async (client, opts) => {
|
|
1966
|
+
const body = { name: opts.name, ...templateBody(opts) };
|
|
1967
|
+
if (opts.channel !== undefined)
|
|
1968
|
+
body.channel = opts.channel;
|
|
1969
|
+
if (opts.inactive)
|
|
1970
|
+
body.isActive = false;
|
|
1971
|
+
if (body.channel === undefined)
|
|
1972
|
+
throw new Error("--channel is required (sms | email).");
|
|
1973
|
+
return client.post("/api/v1/emails/templates", body);
|
|
1974
|
+
}));
|
|
1975
|
+
templateBodyFlags(templates
|
|
1976
|
+
.command("update <id>")
|
|
1977
|
+
.description(`Update a template (PATCH /emails/templates/{id}; a partial update: send only what changes). To change a template's mode pass --body-mode (html with --html / --html-file, or blocks with --body-html); a stored HTML-mode template stays in HTML mode when only --body-html is sent, and switching back to blocks keeps the pasted HTML. ${TEMPLATE_REFUSALS}`)
|
|
1978
|
+
.option("--name <name>", "new name")
|
|
1979
|
+
.option("--active <bool>", "true | false")).action(run(async (client, id, opts) => {
|
|
1980
|
+
assertUuid(id, "template id");
|
|
1981
|
+
const body = templateBody(opts);
|
|
1982
|
+
if (opts.name !== undefined)
|
|
1983
|
+
body.name = opts.name;
|
|
1984
|
+
if (opts.active !== undefined)
|
|
1985
|
+
body.isActive = boolFlag(opts.active, "--active");
|
|
1986
|
+
if (Object.keys(body).length === 0)
|
|
1987
|
+
throw new Error("Nothing to change: pass at least one of --name, --subject, --body, --body-html, --body-mode, --html, --category, --active or --json.");
|
|
1988
|
+
return client.patch(`/api/v1/emails/templates/${encodeURIComponent(id.trim())}`, body);
|
|
1989
|
+
}));
|
|
1990
|
+
const sms = program.command("sms").description("SMS providers");
|
|
1991
|
+
const smsProviders = sms.command("providers").description("The workspace's SMS providers");
|
|
1992
|
+
smsProviders
|
|
1993
|
+
.command("options")
|
|
1994
|
+
.description("The workspace's active SMS providers, redacted (GET /sms-providers/template-options): [{ id, name, color }] ordered by name, EVERY active provider (builtin included) and nothing else: no webhook URL, headers, field mapping, counts or credential. Use an id or name to bind a template to a provider. Read-only; API key scope emails, sms, messages or settings, and a member who may view email templates (email_templates.view) or settings (settings.view). A 403 exits 1 with the API's message")
|
|
1995
|
+
.action(run(async (client) => client.get("/api/v1/sms-providers/template-options")));
|
|
1885
1996
|
messages
|
|
1886
1997
|
.command("conversations")
|
|
1887
1998
|
.description("List conversation threads")
|
|
@@ -2937,6 +3048,62 @@ mailchimp
|
|
|
2937
3048
|
.command("confirm-reconciliation")
|
|
2938
3049
|
.description("Confirm the completed reconciliation's report and resume the unsubscribe check after the blocked second (owner / admin key; audited with the report's counts). 409 when nothing is blocked, a reconciliation is still running, or none completed since the block began")
|
|
2939
3050
|
.action(run(async (client) => client.post("/api/v1/integrations/mailchimp/confirm-reconciliation", {}, { timeoutMs: MAILCHIMP_ADMIN_TIMEOUT_MS })));
|
|
3051
|
+
// ---- Calendly (app 10-09): connect is browser-only (OAuth 2.0 + PKCE), so there is no `connect` here ----
|
|
3052
|
+
const calendly = integrations
|
|
3053
|
+
.command("calendly")
|
|
3054
|
+
.description("Calendly: a user's own connection syncs the chosen event types as appointments (API key scope integrations; the writes need a write or admin tier key and calendar.edit). Connecting is browser-only (OAuth 2.0 + PKCE): the user opens {app}/api/v1/integrations/calendly/oauth/install while signed in (Settings > Integrations > Calendly); API keys are refused there (scope integrations-oauth). Errors exit 1 with the server's message (404 not connected, 409 revoked: reconnect in the browser, 502 Calendly refused)");
|
|
3055
|
+
calendly
|
|
3056
|
+
.command("status")
|
|
3057
|
+
.description("The Calendly connection (GET /integrations/calendly/status): { configured, description, importLine, connected, connection (the key's member's own, or null), connections (an owner / admin key: every user's) }. A connection: id, userId, status, calendlyEmail, calendlyName, eventTypes, selectedEventTypeUris, selectionSaved (nothing syncs before a selection is saved), liveSync (on | pending | plan_refused with planWarning: the Calendly plan has no webhooks, so only the import runs), import { status (idle | queued | running | done | failed), eventsSeen, imported, skipped, startedAt, finishedAt, error }, lastBookingAt, lastError, lastErrorAt, createdAt. Tokens are never returned")
|
|
3058
|
+
.action(run(async (client) => client.get("/api/v1/integrations/calendly/status")));
|
|
3059
|
+
calendly
|
|
3060
|
+
.command("import-progress")
|
|
3061
|
+
.description("The Calendly import's progress: prints the selected connection UNCHANGED, as the status route answers it (id, userId, status, calendlyEmail, calendlyName, eventTypes, selectedEventTypeUris, selectionSaved, liveSync, planWarning, import { status, eventsSeen, imported, skipped, startedAt, finishedAt, error }, lastBookingAt, lastError, lastErrorAt, createdAt). import.status is idle | queued | running | done | failed: poll until it is done or failed (import.error says why); idle = no selection saved yet. The import (upcoming events and the last 180 days, silent: no automations) starts with the first selection and when a selection adds a type. Without --connection-id: your own connection; with none connected, or an id that is not in the list the status route returns, it exits 1. The whole status answer is validated first: a malformed one exits 1 and prints nothing")
|
|
3062
|
+
.option("--connection-id <id>", "an owner / admin may name another user's connection (from `status`)")
|
|
3063
|
+
.action(run(async (client, opts) => {
|
|
3064
|
+
if (opts.connectionId !== undefined)
|
|
3065
|
+
assertUuid(opts.connectionId, "--connection-id");
|
|
3066
|
+
const status = calendlyStatus(await client.get("/api/v1/integrations/calendly/status"));
|
|
3067
|
+
const pool = [...(status.connection ? [status.connection] : []), ...(status.connections ?? [])];
|
|
3068
|
+
const want = opts.connectionId?.trim().toLowerCase();
|
|
3069
|
+
const conn = want ? pool.find((c) => String(c.id).toLowerCase() === want) : status.connection;
|
|
3070
|
+
if (!conn)
|
|
3071
|
+
throw new Error(want ? `No Calendly connection with id ${opts.connectionId} is visible to this key (see \`integrations calendly status\`).` : "Calendly is not connected for this key's member (connect in the browser; see `integrations calendly --help`).");
|
|
3072
|
+
return { data: conn };
|
|
3073
|
+
}));
|
|
3074
|
+
calendly
|
|
3075
|
+
.command("event-types")
|
|
3076
|
+
.description("Re-read the user's event types from Calendly and show the connection (GET /integrations/calendly/event-types): the fresh eventTypes (the picker's list) and selectedEventTypeUris. Pick URIs from it for `set-event-types`")
|
|
3077
|
+
.option("--connection-id <id>", "an owner / admin may name another user's connection")
|
|
3078
|
+
.action(run(async (client, opts) => {
|
|
3079
|
+
if (opts.connectionId !== undefined)
|
|
3080
|
+
assertUuid(opts.connectionId, "--connection-id");
|
|
3081
|
+
return client.get(`/api/v1/integrations/calendly/event-types${buildQuery({ connectionId: opts.connectionId?.trim() })}`);
|
|
3082
|
+
}));
|
|
3083
|
+
calendly
|
|
3084
|
+
.command("set-event-types")
|
|
3085
|
+
.description("Choose the Calendly event types that sync (PATCH /integrations/calendly/event-types { eventTypeUris, connectionId? }). ONLY these sync: a booking on any other type is ignored. Repeat --uri for each (https://api.calendly.com/event_types/<id>, at most 200, each one of the user's event types: `event-types` lists them); the list REPLACES the selection. --none stops syncing without disconnecting. The first save, or a save that adds a type, starts the import (upcoming events and the last 180 days, silently: no automations); follow it with `import-progress`. Prints { importQueued, connection }. Needs active billing")
|
|
3086
|
+
.option("--uri <uri>", "a Calendly event type URI to sync (repeat for each)", (v, prev = []) => [...prev, v])
|
|
3087
|
+
.option("--none", "an EMPTY selection: stop syncing without disconnecting")
|
|
3088
|
+
.option("--connection-id <id>", "an owner / admin may name another user's connection")
|
|
3089
|
+
.action(run(async (client, opts) => {
|
|
3090
|
+
if (opts.none && opts.uri?.length)
|
|
3091
|
+
throw new Error("Pass either --uri (one or more) or --none, not both.");
|
|
3092
|
+
if (!opts.none && !opts.uri?.length)
|
|
3093
|
+
throw new Error("Pass --uri <event type URI> (repeat for each), or --none to stop syncing.");
|
|
3094
|
+
if (opts.connectionId !== undefined)
|
|
3095
|
+
assertUuid(opts.connectionId, "--connection-id");
|
|
3096
|
+
return client.patch("/api/v1/integrations/calendly/event-types", { eventTypeUris: opts.none ? [] : opts.uri, ...(opts.connectionId !== undefined ? { connectionId: opts.connectionId.trim() } : {}) });
|
|
3097
|
+
}));
|
|
3098
|
+
calendly
|
|
3099
|
+
.command("disconnect")
|
|
3100
|
+
.description("DISCONNECT Calendly (POST /integrations/calendly/disconnect { connectionId? }): removes the Calendly webhook subscription and the connection; it stops taking bookings at once. Appointments already in Conduyt stay. Reconnecting is a browser flow. Prints { disconnected: true, webhookRemoved, cleanupPending }: cleanupPending true = Calendly could not be reached, so Conduyt keeps the record (bookings refused) and retries the webhook removal with backoff. 404 not connected. Without --connection-id it disconnects your own; an owner / admin may name another user's")
|
|
3101
|
+
.option("--connection-id <id>", "an owner / admin may name another user's connection")
|
|
3102
|
+
.action(run(async (client, opts) => {
|
|
3103
|
+
if (opts.connectionId !== undefined)
|
|
3104
|
+
assertUuid(opts.connectionId, "--connection-id");
|
|
3105
|
+
return client.post("/api/v1/integrations/calendly/disconnect", opts.connectionId !== undefined ? { connectionId: opts.connectionId.trim() } : {});
|
|
3106
|
+
}));
|
|
2940
3107
|
// ---------------------------------------------------------------------------
|
|
2941
3108
|
// Sequences (one model for Email and SMS since 2026-09-30): steps send saved templates of the sequence's channel a
|
|
2942
3109
|
// number of days apart inside a send window; a run stops on a reply, a booked meeting, an opt-out, a closed deal or a
|
|
@@ -3559,7 +3726,7 @@ export function effectiveRecordingPolicy(raw) {
|
|
|
3559
3726
|
export function iphoneRecordingDomain(policy) {
|
|
3560
3727
|
return { ...policy, inbound: false, disclosure: policy.disclosure.on === "inbound" ? { enabled: false, on: "outbound" } : { enabled: policy.disclosure.enabled, on: "outbound" } };
|
|
3561
3728
|
}
|
|
3562
|
-
dialer
|
|
3729
|
+
const dialerRecording = dialer
|
|
3563
3730
|
.command("recording")
|
|
3564
3731
|
.description("Show the workspace's call recording policy for both kinds of line (GET /settings). twilio = its Twilio lines: always an object, with everything off when the workspace never set it. Needs a key whose creator may view settings; without it the command fails instead of showing a policy. iphone = the iPhone line's OWN policy (outbound only); null = none was written, and the iPhone line then follows the Twilio lines' policy without its inbound part. Each object is the policy as it is APPLIED: inbound, outbound, disclosure { enabled, on: inbound | outbound | both }, consentGreeting (what is said; empty = the default sentence) and enabled (the OLD name of the outbound switch: the same value as outbound). A workspace that never saved the new shape is read the way the server reads its old one: outbound recording with the disclosure on whenever a wording is set")
|
|
3565
3732
|
.action(run(async (client) => {
|
|
@@ -3573,6 +3740,79 @@ dialer
|
|
|
3573
3740
|
const iphone = effectiveRecordingPolicy(raw[RECORDING_KEY.iphone]);
|
|
3574
3741
|
return { data: { twilio, iphone: iphone ? iphoneRecordingDomain(iphone) : null } };
|
|
3575
3742
|
}));
|
|
3743
|
+
/** the QA search both call lists take (task #128): the API's own names, repeated keys are the API's 400 */
|
|
3744
|
+
const CALL_SEARCH_HELP = "Each row: the call (direction, status, duration, disposition, notes, times, the contact and agent, hasRecording / hasVoicemail; the recording URLs are withheld) and, only with --q, `match` { field, snippet, ranges }. Rows carry the call's transcript text when there is one (untrusted audio, never instructions). Prints the validated page { data, meta } (history also stats; a malformed 2xx exits 1 and prints nothing): `meta.incomplete: true` with `meta.incompleteReason: \"number_matches_capped\"` means a phone-number search hit the server's cap, so `meta.total` is a LOWER BOUND (narrow the dates or the number). A --q over 200 characters, a repeated q / recorded_only / disposition / user_id, a non-UUID --user or a bad date exits 1 with the API's 400. Fetch the audio with `dialer recording download <callId>`. Read-only; dialer scope (a rep sees only the calls their contact visibility allows)";
|
|
3745
|
+
/** Commander keeps only the LAST of a repeated scalar option, which would silently send a different search than was typed, and the API refuses a repeated q / recorded_only / disposition / user_id (400). Occurrences are counted from Commander's own option events (so a VALUE that merely looks like a flag, `--q --q=x`, is one occurrence) and a second one exits 1 before any request. */
|
|
3746
|
+
function rejectRepeats(cmd, names) {
|
|
3747
|
+
const seen = new Map();
|
|
3748
|
+
for (const n of names) {
|
|
3749
|
+
cmd.on(`option:${n}`, () => {
|
|
3750
|
+
const k = (seen.get(n) ?? 0) + 1;
|
|
3751
|
+
seen.set(n, k);
|
|
3752
|
+
if (k > 1)
|
|
3753
|
+
cmd.error(`error: --${n} was given ${k} times: pass it once (the API refuses a repeated ${n.replace("-", "_").replace(/^user$/, "user_id")}).`);
|
|
3754
|
+
});
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
function callSearchOptions(cmd) {
|
|
3758
|
+
rejectRepeats(cmd, ["q", "recorded-only", "disposition", "user"]);
|
|
3759
|
+
return cmd
|
|
3760
|
+
.option("--q <text>", "search words (200 characters at most): the contact's name, a phone number, the agent's name and the call's transcript (NOT the call's notes)")
|
|
3761
|
+
.option("--recorded-only", "only calls that have a recording (sent as recorded_only=1)")
|
|
3762
|
+
.option("--disposition <label>", "exact disposition label")
|
|
3763
|
+
.option("--user <id>", "only calls handled by this agent (user UUID)")
|
|
3764
|
+
.option("--from <date>", "calls created on/after this date (ISO 8601; date_from)")
|
|
3765
|
+
.option("--to <date>", "calls created on/before this date (ISO 8601; date_to)")
|
|
3766
|
+
.option("--direction <direction>", "inbound | outbound")
|
|
3767
|
+
.option("--status <status>", "call status, e.g. completed, no-answer, voicemail");
|
|
3768
|
+
}
|
|
3769
|
+
function callSearchQuery(opts) {
|
|
3770
|
+
if (opts.user !== undefined)
|
|
3771
|
+
assertUuid(opts.user, "--user");
|
|
3772
|
+
return buildQuery({
|
|
3773
|
+
q: opts.q,
|
|
3774
|
+
recorded_only: opts.recordedOnly ? "1" : undefined,
|
|
3775
|
+
disposition: opts.disposition,
|
|
3776
|
+
user_id: opts.user,
|
|
3777
|
+
date_from: opts.from,
|
|
3778
|
+
date_to: opts.to,
|
|
3779
|
+
direction: opts.direction,
|
|
3780
|
+
status: opts.status,
|
|
3781
|
+
mine: opts.mine ? "true" : undefined,
|
|
3782
|
+
page: opts.page,
|
|
3783
|
+
per_page: opts.limit,
|
|
3784
|
+
});
|
|
3785
|
+
}
|
|
3786
|
+
callSearchOptions(dialer
|
|
3787
|
+
.command("history")
|
|
3788
|
+
.description(`Search and filter the dialer's call log for QA (GET /dialer/history, newest first, up to 200 per page): find the recorded calls to review by words, number, disposition, agent and dates. ${CALL_SEARCH_HELP}. Also prints stats { total, inbound, outbound, avgTalkSeconds } for the whole visible log`))
|
|
3789
|
+
.option("--mine", "only the key's own member's calls")
|
|
3790
|
+
.option("--page <n>", "page number")
|
|
3791
|
+
.option("--limit <n>", "results per page (per_page, default 50, max 200)")
|
|
3792
|
+
.action(run(async (client, opts) => ({ data: historyPage(await client.get(`/api/v1/dialer/history${callSearchQuery(opts)}`)) })));
|
|
3793
|
+
callSearchOptions(dialer
|
|
3794
|
+
.command("calls")
|
|
3795
|
+
.description(`The dialer's recordings / voicemail list with the same QA search and filters (GET /dialer/calls, newest first, up to 100 per page; --status voicemail lists the live voicemails only). ${CALL_SEARCH_HELP}`))
|
|
3796
|
+
.option("--page <n>", "page number")
|
|
3797
|
+
.option("--limit <n>", "results per page (per_page, default 30, max 100)")
|
|
3798
|
+
.action(run(async (client, opts) => ({ data: callsPage(await client.get(`/api/v1/dialer/calls${callSearchQuery(opts)}`)) })));
|
|
3799
|
+
dialerRecording
|
|
3800
|
+
.command("download <callId>")
|
|
3801
|
+
.description("Download a call's audio to a file (GET /dialer/call/{id}/media?type=recording&download=1): the SAME authorisation and stream as playing it, streamed to disk. Saved as -o <file>, else under the name the API gives it (<Contact or number>_<date>_<time>_<callId8>.mp3) in the current folder; never over an existing file unless --force. --voicemail downloads the voicemail instead (type=voicemail). Prints { saved, bytes, contentType }. A call with no media is a 404, a call you may not see is a 404, an unusable media host is a 422 (exit 1 with the API's message; nothing is written)")
|
|
3802
|
+
.option("--voicemail", "the voicemail instead of the recording")
|
|
3803
|
+
.option("-o, --out <file>", "save to this file")
|
|
3804
|
+
.option("--force", "overwrite an existing file")
|
|
3805
|
+
.action(run(async (client, callId, opts) => {
|
|
3806
|
+
assertUuid(callId, "call id");
|
|
3807
|
+
if (opts.out !== undefined && opts.out.trim() === "")
|
|
3808
|
+
throw new Error("-o must be a file path (got an empty string).");
|
|
3809
|
+
const id = callId.trim();
|
|
3810
|
+
return client.download(`/api/v1/dialer/call/${encodeURIComponent(id)}/media${buildQuery({ type: opts.voicemail ? "voicemail" : "recording", download: "1" })}`, {
|
|
3811
|
+
outFile: opts.out ?? null,
|
|
3812
|
+
fallbackName: `${id.slice(0, 8)}.mp3`,
|
|
3813
|
+
overwrite: Boolean(opts.force),
|
|
3814
|
+
});
|
|
3815
|
+
}));
|
|
3576
3816
|
dialer
|
|
3577
3817
|
.command("set-recording")
|
|
3578
3818
|
.description("Change the workspace's call recording policy (admin: settings scope). Send only what changes; the rest stays. The workspace is responsible for consent where it operates. Example: conduyt dialer set-recording --inbound on --outbound on --disclosure on --disclosure-on inbound")
|
package/package.json
CHANGED