conduyt 1.68.0 → 1.70.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 -0
- package/dist/client.js +54 -3
- package/dist/index.js +49 -5
- package/dist/message-check-minimum.js +43 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -79,6 +79,8 @@ conduyt whatsapp templates # approved / pending / rejected template
|
|
|
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
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 messages minimum-grade # the admin's Message check minimum grade to send (settings.messageCheck.minimumGrade): enabled, sms / email minimum (A-F, 0-100 or null), adminOverride; groundwork, stored but not enforced yet; owner / admin only (anyone else sees visible: false)
|
|
83
|
+
conduyt messages set-minimum-grade [--sms A|B|C|D|F|0-100|none] [--email ...] [--admin-override on|off] [--enabled on|off] # sends the whole block with the settings revision it read; --enabled on is refused 400 minimum_grade_not_available_yet; 403 forbidden for a non-owner / non-admin; all exit 1 with the code
|
|
82
84
|
conduyt sms providers options # the workspace's active SMS providers, redacted: [{ id, name, color }] (needs email_templates.view or settings.view)
|
|
83
85
|
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
86
|
conduyt templates get <id> # one template with its full content (an HTML-mode template answers its pasted source in `html`)
|
|
@@ -129,6 +131,8 @@ out and named in `_omitted`. Every HTTP refusal takes this path (JSON calls, tex
|
|
|
129
131
|
Error: Conduyt API 503: Contact merge in progress [Retry-After: 5s] {"code":"merge_in_progress"}
|
|
130
132
|
```
|
|
131
133
|
|
|
134
|
+
The contact merge route (`POST /contacts/merge`, no command today) answers a retryable 503 with `Retry-After` and the code `merge_contacts_busy` (a contact stayed busy for the whole lock budget) or `merge_budget_exhausted` (the request's time budget ran out), nothing changed. A command that reaches it prints the refusal in this grammar, exits 1 and does not retry; run it again after the stated seconds.
|
|
135
|
+
|
|
132
136
|
## License
|
|
133
137
|
|
|
134
138
|
MIT
|
package/dist/client.js
CHANGED
|
@@ -83,6 +83,58 @@ export function renderErrorDetails(body) {
|
|
|
83
83
|
out._omitted = omitted;
|
|
84
84
|
return JSON.stringify(out);
|
|
85
85
|
}
|
|
86
|
+
const RA_MAX_SECONDS = 2_147_483_647; // delay-seconds is unbounded 1*DIGIT: a longer value saturates here instead of becoming null
|
|
87
|
+
const RA_MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
|
|
88
|
+
const RA_MON = "Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec";
|
|
89
|
+
const RA_IMF = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\\d{2}) (${RA_MON}) (\\d{4}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`);
|
|
90
|
+
const RA_850 = new RegExp(`^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\\d{2})-(${RA_MON})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`);
|
|
91
|
+
const RA_ASC = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (${RA_MON}) ([ \\d]\\d) (\\d{2}):(\\d{2}):(\\d{2}) (\\d{4})$`);
|
|
92
|
+
/** A UTC timestamp for the parts, or NaN when any part is out of range (31 Feb, 25:00, ...). */
|
|
93
|
+
function raUtc(y, mo, d, h, mi, s) {
|
|
94
|
+
if (y < 1900 || h > 23 || mi > 59 || s > 60)
|
|
95
|
+
return NaN; // RFC 5322: years below 1900 are not dates
|
|
96
|
+
const t = Date.UTC(y, mo, d, h, mi, Math.min(s, 59));
|
|
97
|
+
const back = new Date(t);
|
|
98
|
+
return back.getUTCFullYear() === y && back.getUTCMonth() === mo && back.getUTCDate() === d ? t : NaN;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The ONE Retry-After parser. RFC 9110: delay-seconds (digits; a value too long for a number saturates at 2^31-1) or an
|
|
102
|
+
* HTTP-date in one of the three §5.6.7 forms, parsed explicitly and always as UTC (never the host timezone): IMF-fixdate,
|
|
103
|
+
* RFC 850 (two-digit year: more than 50 years ahead of `nowMs` means the previous century) and asctime. A date becomes the
|
|
104
|
+
* remaining seconds, floored at 0. Absent, blank or anything else (ISO timestamps, other zones, "-3") is null.
|
|
105
|
+
*/
|
|
106
|
+
export function parseRetryAfter(raw, nowMs = Date.now()) {
|
|
107
|
+
if (raw === null || raw === undefined)
|
|
108
|
+
return null;
|
|
109
|
+
const v = raw.trim();
|
|
110
|
+
if (v === "")
|
|
111
|
+
return null;
|
|
112
|
+
if (/^\d+(\.\d+)?$/.test(v)) {
|
|
113
|
+
const n = Number(v);
|
|
114
|
+
return Number.isNaN(n) ? null : Math.min(n, RA_MAX_SECONDS);
|
|
115
|
+
}
|
|
116
|
+
let t = NaN;
|
|
117
|
+
let m;
|
|
118
|
+
if ((m = RA_IMF.exec(v)))
|
|
119
|
+
t = raUtc(Number(m[3]), RA_MONTHS[m[2]], Number(m[1]), Number(m[4]), Number(m[5]), Number(m[6]));
|
|
120
|
+
else if ((m = RA_850.exec(v))) {
|
|
121
|
+
const century = Math.floor(new Date(nowMs).getUTCFullYear() / 100) * 100;
|
|
122
|
+
const mo = RA_MONTHS[m[2]], d = Number(m[1]), h = Number(m[4]), mi = Number(m[5]), sec = Number(m[6]);
|
|
123
|
+
t = raUtc(century + Number(m[3]), mo, d, h, mi, sec);
|
|
124
|
+
// RFC 9110: a timestamp more than 50 years in the future (the same instant 50 calendar years on, UTC) is the previous century
|
|
125
|
+
// (the day is clamped to the month's last day: 29 Feb + 50 years is 28 Feb, never 1 Mar)
|
|
126
|
+
const n = new Date(nowMs);
|
|
127
|
+
const ly = n.getUTCFullYear() + 50, lm = n.getUTCMonth();
|
|
128
|
+
const limit = Date.UTC(ly, lm, Math.min(n.getUTCDate(), new Date(Date.UTC(ly, lm + 1, 0)).getUTCDate()), n.getUTCHours(), n.getUTCMinutes(), n.getUTCSeconds(), n.getUTCMilliseconds());
|
|
129
|
+
if (!Number.isNaN(t) && t > limit)
|
|
130
|
+
t = raUtc(century - 100 + Number(m[3]), mo, d, h, mi, sec);
|
|
131
|
+
}
|
|
132
|
+
else if ((m = RA_ASC.exec(v)))
|
|
133
|
+
t = raUtc(Number(m[6]), RA_MONTHS[m[1]], Number(m[2].trim()), Number(m[3]), Number(m[4]), Number(m[5]));
|
|
134
|
+
if (Number.isNaN(t))
|
|
135
|
+
return null;
|
|
136
|
+
return Math.max(0, Math.ceil((t - nowMs) / 1000));
|
|
137
|
+
}
|
|
86
138
|
/**
|
|
87
139
|
* The ONE place an HTTP refusal becomes an Error (request, postText and stream all use it). Grammar (stable):
|
|
88
140
|
* `Conduyt API <status>: <message>[ [Retry-After: <N>s]][ <details>]`; <details> is ONE JSON object and the FINAL token
|
|
@@ -90,9 +142,8 @@ export function renderErrorDetails(body) {
|
|
|
90
142
|
*/
|
|
91
143
|
export function formatHttpError(res, text) {
|
|
92
144
|
const json = text ? safeParse(text) : null;
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
const raTag = Number.isFinite(raN) && raN >= 0 ? ` [Retry-After: ${raN}s]` : "";
|
|
145
|
+
const raN = parseRetryAfter(res.headers.get("retry-after"));
|
|
146
|
+
const raTag = raN !== null ? ` [Retry-After: ${raN}s]` : "";
|
|
96
147
|
const obj = json && typeof json === "object" && !Array.isArray(json) ? json : null;
|
|
97
148
|
if (obj && typeof obj.error === "string" && obj.error) {
|
|
98
149
|
const extras = renderErrorDetails(obj);
|
package/dist/index.js
CHANGED
|
@@ -3,8 +3,9 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { addPoolFilterSet, listPoolFilterSets, removePoolFilterSet, updatePoolFilterSet } from "./pool-filter-sets.js";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
|
-
import { Command } from "commander";
|
|
6
|
+
import { Command, CommanderError } from "commander";
|
|
7
7
|
import { ConduytClient, buildQuery } from "./client.js";
|
|
8
|
+
import { minimumGradeOf, minimumGradePatch, parseGradeFlag } from "./message-check-minimum.js";
|
|
8
9
|
import { splitList, stageRulePatch, validateCardFields, validateRuleChange } from "./board-settings.js";
|
|
9
10
|
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
11
|
import { saveConfig, resolveConfig, configPath } from "./config.js";
|
|
@@ -41,7 +42,11 @@ const program = new Command();
|
|
|
41
42
|
program
|
|
42
43
|
.name("conduyt")
|
|
43
44
|
.description("Command-line interface for Conduyt CRM — manage contacts, deals, pipelines, and run insight queries from your terminal.")
|
|
44
|
-
.version(readVersion())
|
|
45
|
+
.version(readVersion())
|
|
46
|
+
// Commander ends --help / --version / usage errors with process.exit() right after writing, and a piped stdout is not drained by
|
|
47
|
+
// then (`conduyt messages set-minimum-grade --help | cat` was cut at exactly 512 bytes). exitOverride is set BEFORE any subcommand
|
|
48
|
+
// is defined, so every command inherits it; main() below drains both streams and only then exits with Commander's own exit code.
|
|
49
|
+
.exitOverride();
|
|
45
50
|
// ---- config ----
|
|
46
51
|
const config = program.command("config").description("Manage CLI configuration");
|
|
47
52
|
config
|
|
@@ -85,7 +90,7 @@ contacts
|
|
|
85
90
|
.action(run(async (client, id) => client.get(`/api/v1/contacts/${encodeURIComponent(id)}`)));
|
|
86
91
|
contacts
|
|
87
92
|
.command("create")
|
|
88
|
-
.description("Create a contact")
|
|
93
|
+
.description("Create a contact. An impossible US/Canada number (area code or exchange starting 0 or 1) is refused 400 phone_invalid and nothing is saved; the refusal exits 1 with the status, message and code, sent once")
|
|
89
94
|
.option("--first <name>", "first name")
|
|
90
95
|
.option("--last <name>", "last name")
|
|
91
96
|
.option("--email <email>", "email")
|
|
@@ -130,7 +135,7 @@ contacts
|
|
|
130
135
|
}));
|
|
131
136
|
contacts
|
|
132
137
|
.command("update <id>")
|
|
133
|
-
.description("Update a contact (PATCH — only the fields you pass change)")
|
|
138
|
+
.description("Update a contact (PATCH — only the fields you pass change). An impossible US/Canada number (area code or exchange starting 0 or 1) is refused 400 phone_invalid; a country is never null or blank (400 country_invalid, via --json); each refusal exits 1 with the status, message and code, sent once")
|
|
134
139
|
.option("--first <name>", "first name")
|
|
135
140
|
.option("--last <name>", "last name")
|
|
136
141
|
.option("--email <email>", "email")
|
|
@@ -1890,6 +1895,36 @@ messages
|
|
|
1890
1895
|
throw new Error("The draft is required. Pass --body '<text>' or --body-file <path>.");
|
|
1891
1896
|
return client.post("/api/v1/messages/check", body);
|
|
1892
1897
|
}));
|
|
1898
|
+
const MINIMUM_GRADE_NOTE = "Groundwork only: the minimum is stored and read back but NOT enforced yet (Send stays enabled for everyone), and --enabled on is refused 400 minimum_grade_not_available_yet until the feature is switched on.";
|
|
1899
|
+
messages
|
|
1900
|
+
.command("minimum-grade")
|
|
1901
|
+
.description(`Show the admin's Message check minimum grade to send (GET /settings, settings.messageCheck.minimumGrade). Prints { minimumGrade: { enabled, channel: { sms, email }, adminOverride } | null, visible } (the standard envelope is unwrapped). enabled is the master switch (default off); channel.sms / channel.email the lowest acceptable result for that channel (a letter A-F, a score 0-100 or null = no minimum); adminOverride whether an owner or admin may send below it (default on). ${MINIMUM_GRADE_NOTE} Owner / admin only: for any other member the API leaves the block out, so minimumGrade is null and visible false (a redaction, not an unset minimum). Needs the settings scope.`)
|
|
1902
|
+
.action(run(async (client) => {
|
|
1903
|
+
const result = (await client.get("/api/v1/settings"));
|
|
1904
|
+
const block = minimumGradeOf(result?.data);
|
|
1905
|
+
return { data: { minimumGrade: block, visible: block !== null } };
|
|
1906
|
+
}));
|
|
1907
|
+
messages
|
|
1908
|
+
.command("set-minimum-grade")
|
|
1909
|
+
.description(`Change the Message check minimum grade to send (PATCH /settings). Pass only what changes: --sms / --email <A-F|0-100|none> (a letter, a whole score, or none for no minimum; E, fractions and scores over 100 are refused), --admin-override on|off, --enabled on|off. Sends the WHOLE block (the API replaces it) with the settings revision it read; a 409 re-reads once, then exits 1 with the current settings. ${MINIMUM_GRADE_NOTE} Owner / admin only. Every refusal exits 1 with the status, the API's message and its code, sent once and never retried: 403 forbidden ("Only an owner or admin can change the Message check minimum grade"), 400 minimum_grade_not_available_yet, 422 invalid_body. No new scope: the settings scope, settings:edit and the owner or admin role.`)
|
|
1910
|
+
.option("--sms <grade>", "SMS minimum: A, B, C, D, F, a score 0-100, or none")
|
|
1911
|
+
.option("--email <grade>", "email minimum: A, B, C, D, F, a score 0-100, or none")
|
|
1912
|
+
.option("--admin-override <on|off>", "whether an owner or admin may send below the minimum (default on)")
|
|
1913
|
+
.option("--enabled <on|off>", "master switch; on is refused 400 minimum_grade_not_available_yet until the feature is switched on")
|
|
1914
|
+
.action(run(async (client, opts) => {
|
|
1915
|
+
const change = {};
|
|
1916
|
+
if (opts.sms !== undefined)
|
|
1917
|
+
change.sms = parseGradeFlag(opts.sms, "--sms");
|
|
1918
|
+
if (opts.email !== undefined)
|
|
1919
|
+
change.email = parseGradeFlag(opts.email, "--email");
|
|
1920
|
+
if (opts.adminOverride !== undefined)
|
|
1921
|
+
change.adminOverride = onOffArg(opts.adminOverride, "--admin-override");
|
|
1922
|
+
if (opts.enabled !== undefined)
|
|
1923
|
+
change.enabled = onOffArg(opts.enabled, "--enabled");
|
|
1924
|
+
if (Object.keys(change).length === 0)
|
|
1925
|
+
throw new Error("nothing to change: pass at least one of --sms, --email, --admin-override, --enabled");
|
|
1926
|
+
return patchGeneralSettingsMerged(client, (settings) => minimumGradePatch(settings, change));
|
|
1927
|
+
}));
|
|
1893
1928
|
// ---- email / SMS templates (pasted-HTML email templates, #127, 2026-10-09) ----
|
|
1894
1929
|
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
1930
|
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";
|
|
@@ -5296,7 +5331,16 @@ smartViews
|
|
|
5296
5331
|
assertUuid(id, "smart view id");
|
|
5297
5332
|
return client.patch("/api/v1/smart-views/reorder", { order: ids });
|
|
5298
5333
|
}));
|
|
5299
|
-
|
|
5334
|
+
/** end the process only after stdout and stderr have flushed (a pipe is not drained at process.exit) */
|
|
5335
|
+
function exitAfterFlush(code) {
|
|
5336
|
+
process.stdout.write("", () => process.stderr.write("", () => process.exit(code)));
|
|
5337
|
+
}
|
|
5338
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
5339
|
+
// help, version and usage errors: Commander already printed them; keep its exit code, but drain first
|
|
5340
|
+
if (err instanceof CommanderError)
|
|
5341
|
+
return exitAfterFlush(err.exitCode);
|
|
5342
|
+
fail(err);
|
|
5343
|
+
});
|
|
5300
5344
|
// helpers
|
|
5301
5345
|
// Parse a --json / --stages style argument, throwing a clear CLI error (caught
|
|
5302
5346
|
// by run() -> non-zero exit) rather than a stack trace on bad JSON.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message check minimum grade, #134 groundwork (app 10-10, LIVE 9:19 AM): settings.messageCheck.minimumGrade, the admin's lowest
|
|
3
|
+
* acceptable Message check result per channel. Stored and read back, enforced nowhere yet; `enabled: true` is refused 400
|
|
4
|
+
* minimum_grade_not_available_yet. Owner / admin only: GET /settings leaves the block out for anyone else and PATCH answers 403.
|
|
5
|
+
* The validators mirror the API's messageCheckSettingsSchema (strict, whole block).
|
|
6
|
+
*/
|
|
7
|
+
export const GRADE_LETTERS = ["A", "B", "C", "D", "F"];
|
|
8
|
+
export const DEFAULT_MINIMUM_GRADE = { enabled: false, channel: { sms: null, email: null }, adminOverride: true };
|
|
9
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
10
|
+
/** a letter A-F (any case), a whole score 0-100, or the literal "none" (null = no minimum); E, fractions and out-of-range scores are refused */
|
|
11
|
+
export function parseGradeFlag(raw, flag) {
|
|
12
|
+
const v = raw.trim();
|
|
13
|
+
if (v.toLowerCase() === "none")
|
|
14
|
+
return null;
|
|
15
|
+
if (GRADE_LETTERS.includes(v.toUpperCase()))
|
|
16
|
+
return v.toUpperCase();
|
|
17
|
+
if (/^\d+$/.test(v) && Number(v) <= 100)
|
|
18
|
+
return Number(v);
|
|
19
|
+
throw new Error(`${flag} takes a letter A, B, C, D or F, a whole score 0-100, or none (got '${raw}').`);
|
|
20
|
+
}
|
|
21
|
+
/** the stored block with the defaults laid under it field by field; null when the API left the block out (the key's member is not an owner / admin) */
|
|
22
|
+
export function minimumGradeOf(settings) {
|
|
23
|
+
const mc = settings?.messageCheck;
|
|
24
|
+
if (!isObj(mc))
|
|
25
|
+
return null;
|
|
26
|
+
const s = isObj(mc.minimumGrade) ? mc.minimumGrade : {};
|
|
27
|
+
const ch = isObj(s.channel) ? s.channel : {};
|
|
28
|
+
const val = (v) => (typeof v === "string" || typeof v === "number" ? v : null);
|
|
29
|
+
return { enabled: s.enabled === true, channel: { sms: val(ch.sms), email: val(ch.email) }, adminOverride: s.adminOverride === false ? false : true };
|
|
30
|
+
}
|
|
31
|
+
/** the PATCH body: the WHOLE block (the API replaces it) with only the given fields changed; a block the key cannot see gets the defaults laid under the change (the API then answers 403 forbidden) */
|
|
32
|
+
export function minimumGradePatch(settings, change) {
|
|
33
|
+
const stored = minimumGradeOf(settings) ?? DEFAULT_MINIMUM_GRADE;
|
|
34
|
+
return {
|
|
35
|
+
messageCheck: {
|
|
36
|
+
minimumGrade: {
|
|
37
|
+
enabled: change.enabled ?? stored.enabled,
|
|
38
|
+
channel: { sms: change.sms === undefined ? stored.channel.sms : change.sms, email: change.email === undefined ? stored.channel.email : change.email },
|
|
39
|
+
adminOverride: change.adminOverride ?? stored.adminOverride,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
package/package.json
CHANGED