conduyt 1.74.0 → 1.76.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 CHANGED
@@ -88,6 +88,10 @@ conduyt templates get <id> # one template with its full content (an HTML-mode
88
88
  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
89
89
  conduyt templates update <id> [--name …] [--body-mode html --html-file email.html] [--active false] # partial update
90
90
  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
91
+ conduyt dialer history --unmatched --line <number> # only calls no contact is linked to, only inbound calls to our line (the unknown-caller counter's list)
92
+ conduyt dialer unknown-callers --line <number> --since <ISO instant> # the counter: { line, since, total, unmatched } (since = the start of day, within 31 days)
93
+ conduyt dialer link-call <callId> --contact <id> [--attach] [--dry-run] # link a contact-less call to a contact; --attach adds its number to the next free phone slot; --dry-run writes nothing
94
+ conduyt contacts create --first X --call <callId> # the lead for a contact-less inbound call, linked in the same transaction (the server sets source Inbound Call and the owner)
91
95
  conduyt dialer calls --status voicemail --q "+1555…" # the recordings / voicemail list with the same search (100 per page)
92
96
  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
93
97
  conduyt privacy export <id> # GDPR data-portability export (owner/admin)
package/dist/index.js CHANGED
@@ -101,9 +101,23 @@ contacts
101
101
  .command("get <id>")
102
102
  .description("Get a single contact by id (includes `revision`: pass it as --expected-revision on `contacts update` / `contacts collaborators`; `hasOutboundSms` is true when the workspace has ever PROVABLY texted the contact: a proven send only, not a draft, a queued row or a pre-send failure; use it to pick `messages check --context`: false = first, true = conversation; `hasOutboundEmail` is the same for email: true when at least one outbound email to the contact was proven sent, so a genuine \"Re:\" reply is a conversation and not flagged)")
103
103
  .action(run(async (client, id) => client.get(`/api/v1/contacts/${encodeURIComponent(id)}`)));
104
+ /** An RFC 3339 instant WITH a Z or an explicit offset (an offset-less value would be read in the runtime's or server's timezone),
105
+ * and a real calendar date and time (new Date() would silently roll 2026-02-31 into March). */
106
+ function assertRfc3339Instant(value, label) {
107
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/i.exec(value.trim());
108
+ const bad = () => new Error(`${label} must be an RFC 3339 instant with Z or an explicit offset, such as 2026-10-15T07:00:00.000Z or 2026-10-15T00:00:00-07:00 (got ${JSON.stringify(value)})`);
109
+ if (!m)
110
+ throw bad();
111
+ const [, y, mo, d, h, mi, se, , oh, om] = m;
112
+ const dt = new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
113
+ if (dt.getUTCFullYear() !== +y || dt.getUTCMonth() !== +mo - 1 || dt.getUTCDate() !== +d || +h > 23 || +mi > 59 || +se > 59)
114
+ throw bad();
115
+ if (oh !== undefined && (+oh > 23 || +om > 59))
116
+ throw bad();
117
+ }
104
118
  contacts
105
119
  .command("create")
106
- .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")
120
+ .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. --call <callId> creates the lead FOR a contact-less inbound call (the unknown-caller prompt's Create new lead): the call is linked in the same transaction, the phone is the call's own number (omit --phone or pass the same number), --first is required, the account's form-required fields are not required for this lead only, the server derives BOTH the source ('Inbound Call') and the owner (the rep who took the call), so --call is refused together with --source, --assigned-to or a source / assignedTo key in --json (nothing is sent); needs dialer.create (a key also needs the calls scope; a dialer key covers it); 409 call_already_linked, 422 call_not_inbound / number_unusable / phone_not_call_number: nothing is created")
107
121
  .option("--first <name>", "first name")
108
122
  .option("--last <name>", "last name")
109
123
  .option("--email <email>", "email")
@@ -112,9 +126,15 @@ contacts
112
126
  .option("--company <company>", "company name (auto-creates/links a company)")
113
127
  .option("--source <source>", "lead source (first-write-wins acquisition truth)")
114
128
  .option("--master-status <status>", "lead lifecycle status: open|won|lost|abandoned|disqualified or an account custom (terminal statuses silence all outbound automation)")
129
+ .option("--assigned-to <userId>", "owner (user id) of the new lead; refused together with --call (the server assigns the rep who took the call)")
130
+ .option("--call <callId>", "the contact-less inbound call this lead is for (sent as callId; linked in the same transaction); refused together with --source / --assigned-to")
115
131
  .option("--json <json>", "full JSON body, merged over the flags above (for any field)")
116
132
  .action(run(async (client, opts) => {
117
133
  const body = {};
134
+ if (opts.call !== undefined) {
135
+ assertUuid(opts.call, "--call");
136
+ body.callId = opts.call.trim();
137
+ }
118
138
  if (opts.first !== undefined)
119
139
  body.firstName = opts.first;
120
140
  if (opts.last !== undefined)
@@ -141,8 +161,17 @@ contacts
141
161
  throw new Error("--master-status must be at most 40 characters.");
142
162
  body.masterStatus = opts.masterStatus;
143
163
  }
164
+ if (opts.assignedTo !== undefined) {
165
+ if (opts.assignedTo.trim() === "")
166
+ throw new Error("--assigned-to was provided but is blank. Omit it or pass a user id.");
167
+ body.assignedTo = opts.assignedTo.trim();
168
+ }
144
169
  if (opts.json !== undefined)
145
170
  Object.assign(body, jsonArg(opts.json, "--json"));
171
+ // checked AFTER flags and --json are merged: the server derives both fields from the call and keeps an explicit assignedTo
172
+ if (body.callId !== undefined && (body.source !== undefined || body.assignedTo !== undefined)) {
173
+ throw new Error("--call cannot be combined with source or assignedTo (--source, --assigned-to, or those keys in --json): for a lead created from a call the server derives both (source 'Inbound Call', owner = the rep who took the call). Omit them.");
174
+ }
146
175
  assertContactSource(body.source, { allowNull: false });
147
176
  return client.post("/api/v1/contacts", body);
148
177
  }));
@@ -3944,6 +3973,8 @@ function callSearchQuery(opts) {
3944
3973
  direction: opts.direction,
3945
3974
  status: opts.status,
3946
3975
  mine: opts.mine ? "true" : undefined,
3976
+ unmatched: opts.unmatched ? "1" : undefined,
3977
+ line: opts.line,
3947
3978
  page: opts.page,
3948
3979
  per_page: opts.limit,
3949
3980
  });
@@ -3952,6 +3983,8 @@ callSearchOptions(dialer
3952
3983
  .command("history")
3953
3984
  .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`))
3954
3985
  .option("--mine", "only the key's own member's calls")
3986
+ .option("--unmatched", "only calls no contact is linked to (the unknown-caller prompt's not-matched calls; sent as unmatched=1)")
3987
+ .option("--line <number>", "our line, any formatting: only INBOUND calls to it (the toll-free counter's link); with --direction outbound it is a 400 line_direction_conflict")
3955
3988
  .option("--page <n>", "page number")
3956
3989
  .option("--limit <n>", "results per page (per_page, default 50, max 200)")
3957
3990
  .action(run(async (client, opts) => ({ data: historyPage(await client.get(`/api/v1/dialer/history${callSearchQuery(opts)}`)) })));
@@ -3961,6 +3994,34 @@ callSearchOptions(dialer
3961
3994
  .option("--page <n>", "page number")
3962
3995
  .option("--limit <n>", "results per page (per_page, default 30, max 100)")
3963
3996
  .action(run(async (client, opts) => ({ data: callsPage(await client.get(`/api/v1/dialer/calls${callSearchQuery(opts)}`)) })));
3997
+ dialer
3998
+ .command("link-call <callId>")
3999
+ .description("Link a contact-less call to an existing contact (POST /calls/{id}/link, the unknown-caller prompt's Search a contact + Attach). --attach also adds the call's number to the contact's next free phone slot (the empty primary, then phone_2, phone_3 ...; nothing is overwritten; already there = no write). --dry-run answers what the link would do (attached.slot, alreadyOnContact, alreadyLinked, or the same refusal) and writes nothing. Refusals write nothing and exit 1 with the status, message and code: 409 call_already_linked / no_free_phone_slot / number_on_another_contact / number_owner_undecidable (too many contacts share the number's last seven digits; link without --attach), 403 contact_not_editable, 404 call_not_found / contact_not_found, 422 number_unusable. A repeat is idempotent (alreadyLinked: true). To create a new lead from the call instead: contacts create --call <callId>. Needs dialer.create, plus contacts.edit with --attach")
4000
+ .requiredOption("--contact <id>", "the contact to link the call to (UUID)")
4001
+ .option("--attach", "also add the call's number to the contact's next free phone slot")
4002
+ .option("--dry-run", "answer what the request would do; write nothing")
4003
+ .action(run(async (client, callId, opts) => {
4004
+ assertUuid(callId, "call id");
4005
+ assertUuid(opts.contact, "--contact");
4006
+ const body = { contactId: opts.contact.trim() };
4007
+ if (opts.attach)
4008
+ body.attachNumber = true;
4009
+ if (opts.dryRun)
4010
+ body.dryRun = true;
4011
+ return client.post(`/api/v1/calls/${encodeURIComponent(callId.trim())}/link`, body);
4012
+ }));
4013
+ dialer
4014
+ .command("unknown-callers")
4015
+ .description("Count a line's unknown callers since an instant (GET /calls/unknown-callers, the toll-free quick mode counter: N unknown callers today, M not matched yet). Prints { line, since, total, unmatched }: total = inbound calls to that line since --since that arrived with no contact (still contact-less plus the ones the unknown-caller prompt linked since), unmatched = those still contact-less (list them with `dialer history --unmatched --line <number>`). --since is the caller's start of day as an ISO instant and must be within the last 31 days. Refusals exit 1 with the API's code: line_required, line_conflict, line_invalid, since_required, since_conflict, since_out_of_range. Read-only, counts only; needs dialer.view (a scoped member counts their own calls, contact-less calls and calls linked to a contact they can see)")
4016
+ .requiredOption("--line <number>", "our line (the number the callers dialed), any formatting")
4017
+ .requiredOption("--since <instant>", "ISO instant, the caller's midnight, e.g. 2026-10-15T07:00:00.000Z (within the last 31 days)")
4018
+ .action(run(async (client, opts) => {
4019
+ if (!opts.line.trim())
4020
+ throw new Error("--line is required");
4021
+ const since = opts.since.trim();
4022
+ assertRfc3339Instant(since, "--since");
4023
+ return client.get(`/api/v1/calls/unknown-callers${buildQuery({ line: opts.line.trim(), since })}`);
4024
+ }));
3964
4025
  dialerRecording
3965
4026
  .command("download <callId>")
3966
4027
  .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)")
@@ -4027,16 +4088,17 @@ dialer
4027
4088
  }));
4028
4089
  dialer
4029
4090
  .command("settings")
4030
- .description("Show the account's Smart Dialing policy (GET /dialer/settings, dialer scope): autoAdvanceEnabled, pauseSeconds, advanceOn (disposition | timer), openLeadOnDial (the lead's record opens as each Smart Dialing call goes out), smartDialCallerId (local_presence = an area-code match from the number pool, the rep's own line when no pool exists; did = always the rep's own line), plus localPresenceConfigured.")
4091
+ .description("Show the account's Smart Dialing policy (GET /dialer/settings, dialer scope): autoAdvanceEnabled, pauseSeconds, advanceOn (disposition | timer), openLeadOnDial (the lead's record opens as each Smart Dialing call goes out), smartDialCallerId (local_presence = an area-code match from the number pool, the rep's own line when no pool exists; did = always the rep's own line), wrapupAutoReleaseSeconds (seconds of continuous wrap-up after which a rep returns to Available automatically; 0 = off, otherwise 30 to 3600; default 300), plus localPresenceConfigured.")
4031
4092
  .action(run(async (client) => client.get("/api/v1/dialer/settings")));
4032
4093
  dialer
4033
4094
  .command("set-settings")
4034
- .description("Change the account's Smart Dialing policy (PATCH /dialer/settings, settings scope). Pass only what changes: --auto-advance on|off, --pause-seconds 1-60, --advance-on disposition|timer, --open-lead on|off (the lead's record opens as each Smart Dialing call goes out), --calls-from local_presence|did.")
4095
+ .description("Change the account's Smart Dialing policy (PATCH /dialer/settings, settings scope). Pass only what changes: --auto-advance on|off, --pause-seconds 1-60, --advance-on disposition|timer, --open-lead on|off (the lead's record opens as each Smart Dialing call goes out), --calls-from local_presence|did, --wrapup-seconds 0|30-3600 (seconds of continuous wrap-up after which a rep returns to Available automatically; 0 = off, otherwise 30-3600).")
4035
4096
  .option("--auto-advance <on|off>", "start the next dial automatically after a call")
4036
4097
  .option("--pause-seconds <n>", "seconds between calls when auto-advance is on (1-60)")
4037
4098
  .option("--advance-on <mode>", "disposition | timer")
4038
4099
  .option("--open-lead <on|off>", "open the lead's record as each Smart Dialing call goes out")
4039
4100
  .option("--calls-from <line>", "local_presence | did")
4101
+ .option("--wrapup-seconds <n>", "seconds of continuous wrap-up after which a rep returns to Available automatically: 0 = off, otherwise 30-3600")
4040
4102
  .action(run(async (client, opts) => {
4041
4103
  const body = {};
4042
4104
  if (opts.autoAdvance !== undefined)
@@ -4061,8 +4123,16 @@ dialer
4061
4123
  throw new Error(`--calls-from must be one of ${SMART_DIAL_CALLER_IDS.join(", ")}.`);
4062
4124
  body.smartDialCallerId = line;
4063
4125
  }
4126
+ if (opts.wrapupSeconds !== undefined) {
4127
+ // digits only: Number() would read "" as 0 (switching the timer off) and "1e2" / "0x1e" / "30.0" as numbers
4128
+ const raw = opts.wrapupSeconds.trim();
4129
+ const n = /^\d{1,5}$/.test(raw) ? Number(raw) : NaN;
4130
+ if (!(n === 0 || (n >= 30 && n <= 3600)))
4131
+ throw new Error("--wrapup-seconds must be 0 (off) or a whole number of seconds from 30 to 3600.");
4132
+ body.wrapupAutoReleaseSeconds = n;
4133
+ }
4064
4134
  if (Object.keys(body).length === 0)
4065
- throw new Error("Nothing to change: pass at least one of --auto-advance, --pause-seconds, --advance-on, --open-lead, --calls-from.");
4135
+ throw new Error("Nothing to change: pass at least one of --auto-advance, --pause-seconds, --advance-on, --open-lead, --calls-from, --wrapup-seconds.");
4066
4136
  return client.patch("/api/v1/dialer/settings", body);
4067
4137
  }));
4068
4138
  dialer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt",
3
- "version": "1.74.0",
3
+ "version": "1.76.0",
4
4
  "description": "Command-line interface for Conduyt CRM — manage contacts, deals, pipelines, and run insight queries from your terminal.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",