conduyt 1.75.0 → 1.77.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
@@ -35,6 +35,8 @@ conduyt contacts list --limit 25 # list contacts (supports --cursor, --qu
35
35
  conduyt contacts get <id> # fetch one contact (carries `revision`, `hasOutboundSms` and `hasOutboundEmail`: a proven send only)
36
36
  conduyt contacts update <id> --first Ann --expected-revision <n> # PATCH; a contact changed since revision n is refused: 409 contact_changed, exit 1, code and current revision printed
37
37
  conduyt contacts merge <primaryId> <secondaryId...> [--confirm [--yes]] # DESTRUCTIVE: a dry run by default (prints the impact); --confirm shows it, asks y/N, then merges (the losers are deleted); --yes skips the question; moved counts 409 and 503 merge_contacts_busy / merge_budget_exhausted + Retry-After exit 1, never retried
38
+ conduyt contacts duplicates <id> # GET /contacts/{id}/duplicate-proposals; prints { proposals, heldIdentifiers } (open and not_sure possible duplicates; read-only)
39
+ conduyt contacts duplicates decide <id> <proposalId> --as not_sure|different|merge # POST the decision; not_sure keeps both visible, different is final, merge only records a pair ALREADY merged (never merges); 409 already_decided / not_merged and 403 other_record_not_editable exit 1
38
40
  conduyt contacts collaborators add <contactId> <userId> [--expected-revision <n>] # POST /contacts/{id}/collaborators; prints { added, revision }
39
41
  conduyt contacts collaborators remove <contactId> <userId> [--expected-revision <n>] # DELETE with a body; prints { removed, revision }; 404 when not a collaborator
40
42
  conduyt chat channels # GET /chat/channels: your channels; also CREATES your auto-channel memberships the first time
@@ -88,6 +90,10 @@ conduyt templates get <id> # one template with its full content (an HTML-mode
88
90
  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
91
  conduyt templates update <id> [--name …] [--body-mode html --html-file email.html] [--active false] # partial update
90
92
  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
93
+ 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)
94
+ conduyt dialer unknown-callers --line <number> --since <ISO instant> # the counter: { line, since, total, unmatched } (since = the start of day, within 31 days)
95
+ 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
96
+ 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
97
  conduyt dialer calls --status voicemail --q "+1555…" # the recordings / voicemail list with the same search (100 per page)
92
98
  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
99
  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
  }));
@@ -226,6 +255,28 @@ contacts
226
255
  assertUuid(id, "contact id");
227
256
  return client.del(`/api/v1/contacts/${encodeURIComponent(id)}`);
228
257
  }));
258
+ // Identity rules v2 (board #69, app main 61bf61876): the possible-duplicate lines on a contact, and deciding one.
259
+ const PROPOSAL_DECISIONS = ["not_sure", "different", "merge"];
260
+ const duplicates = contacts
261
+ .command("duplicates <id>")
262
+ .description("List the possible duplicates of contact <id> (GET /contacts/{id}/duplicate-proposals, contacts:view, read-only): prints { proposals, heldIdentifiers }. Each proposal has id (the <proposalId> for `contacts duplicates decide`), status (open | not_sure), rule, raisedHere and `other` { id, name, createdAt, visible, editable }; a record you cannot view is \"a contact you cannot view\" with no id. heldIdentifiers are emails or phones this record was not given because another record owns them (value-free). Decide a pair with `conduyt contacts duplicates decide <id> <proposalId> --as not_sure|different|merge`")
263
+ .action(run(async (client, id) => {
264
+ assertUuid(id, "contact id");
265
+ return client.get(`/api/v1/contacts/${encodeURIComponent(id)}/duplicate-proposals`);
266
+ }));
267
+ duplicates
268
+ .command("decide <id> <proposalId>")
269
+ .description("Record a decision on one possible-duplicate pair (POST /contacts/{id}/duplicate-proposals/{proposalId}; contacts:edit on BOTH records). --as is required, exactly one of: not_sure (keep both records visible; the pair stays listed and can be decided again), different (the two are different people: never shown again, FINAL), merge (record that the pair is ALREADY merged; this command NEVER merges: run `conduyt contacts merge` first, else 409 not_merged). Prints { id, status, decidedAt }. Refusals exit 1 with the status, message and code and are never retried: 409 already_decided, 409 not_merged, 403 other_record_not_editable, 404, 400")
270
+ .option("--as <decision>", "not_sure | different | merge (required)")
271
+ .action(run(async (client, id, proposalId, opts) => {
272
+ assertUuid(id, "contact id");
273
+ assertUuid(proposalId, "proposal id");
274
+ if (opts.as === undefined)
275
+ throw new Error(`--as is required: one of ${PROPOSAL_DECISIONS.join(", ")}.`);
276
+ if (!PROPOSAL_DECISIONS.includes(opts.as))
277
+ throw new Error(`--as must be exactly one of ${PROPOSAL_DECISIONS.join(", ")} (got "${opts.as}").`);
278
+ return client.post(`/api/v1/contacts/${encodeURIComponent(id)}/duplicate-proposals/${encodeURIComponent(proposalId)}`, { decision: opts.as });
279
+ }));
229
280
  contacts
230
281
  .command("merge <primaryId> <secondaryIds...>")
231
282
  .description("DESTRUCTIVE merge of duplicate contacts (POST /contacts/merge; owner or admin): <primaryId> survives and receives the records of every <secondaryIds> contact, which are then DELETED (no undelete). By default a DRY RUN: nothing changes, it prints the impact { primary, secondaries, counts, totalRecords, guardCounts, filledFields, identifierOverflow, blockers }. --confirm repeats the dry run, shows the impact (the records that move, and separately the guard counts: chat messages, quick notes, identifiers, outbox rows, which are not in the records total), asks y/N and then merges, sending the values that dry run returned (expectedCounts, expectedGuardCounts, expectedIdentifiers). LOSER ORDER: losers are merged in the order given: for each field the survivor lacks, the first loser in this order with a value wins; reorder to change precedence; the dry run's filledFields shows which fields will be filled. WHAT IS PINNED: the server refuses (409) if the record counts, guard counts or identifier plan moved; other field edits made after this preview (a loser's name, company, source, address) are not detected; --yes skips the question (scripts; needs --confirm). A refusal exits 1 with the status, the API's message and its code and is NEVER retried: 409 \"Merge impact changed\" (preview again), 409 queued work, 400 / 404, and 503 merge_contacts_busy / merge_budget_exhausted with `[Retry-After: <N>s]` (nothing changed: wait that long, then run again).")
@@ -3944,6 +3995,8 @@ function callSearchQuery(opts) {
3944
3995
  direction: opts.direction,
3945
3996
  status: opts.status,
3946
3997
  mine: opts.mine ? "true" : undefined,
3998
+ unmatched: opts.unmatched ? "1" : undefined,
3999
+ line: opts.line,
3947
4000
  page: opts.page,
3948
4001
  per_page: opts.limit,
3949
4002
  });
@@ -3952,6 +4005,8 @@ callSearchOptions(dialer
3952
4005
  .command("history")
3953
4006
  .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
4007
  .option("--mine", "only the key's own member's calls")
4008
+ .option("--unmatched", "only calls no contact is linked to (the unknown-caller prompt's not-matched calls; sent as unmatched=1)")
4009
+ .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
4010
  .option("--page <n>", "page number")
3956
4011
  .option("--limit <n>", "results per page (per_page, default 50, max 200)")
3957
4012
  .action(run(async (client, opts) => ({ data: historyPage(await client.get(`/api/v1/dialer/history${callSearchQuery(opts)}`)) })));
@@ -3961,6 +4016,34 @@ callSearchOptions(dialer
3961
4016
  .option("--page <n>", "page number")
3962
4017
  .option("--limit <n>", "results per page (per_page, default 30, max 100)")
3963
4018
  .action(run(async (client, opts) => ({ data: callsPage(await client.get(`/api/v1/dialer/calls${callSearchQuery(opts)}`)) })));
4019
+ dialer
4020
+ .command("link-call <callId>")
4021
+ .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")
4022
+ .requiredOption("--contact <id>", "the contact to link the call to (UUID)")
4023
+ .option("--attach", "also add the call's number to the contact's next free phone slot")
4024
+ .option("--dry-run", "answer what the request would do; write nothing")
4025
+ .action(run(async (client, callId, opts) => {
4026
+ assertUuid(callId, "call id");
4027
+ assertUuid(opts.contact, "--contact");
4028
+ const body = { contactId: opts.contact.trim() };
4029
+ if (opts.attach)
4030
+ body.attachNumber = true;
4031
+ if (opts.dryRun)
4032
+ body.dryRun = true;
4033
+ return client.post(`/api/v1/calls/${encodeURIComponent(callId.trim())}/link`, body);
4034
+ }));
4035
+ dialer
4036
+ .command("unknown-callers")
4037
+ .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)")
4038
+ .requiredOption("--line <number>", "our line (the number the callers dialed), any formatting")
4039
+ .requiredOption("--since <instant>", "ISO instant, the caller's midnight, e.g. 2026-10-15T07:00:00.000Z (within the last 31 days)")
4040
+ .action(run(async (client, opts) => {
4041
+ if (!opts.line.trim())
4042
+ throw new Error("--line is required");
4043
+ const since = opts.since.trim();
4044
+ assertRfc3339Instant(since, "--since");
4045
+ return client.get(`/api/v1/calls/unknown-callers${buildQuery({ line: opts.line.trim(), since })}`);
4046
+ }));
3964
4047
  dialerRecording
3965
4048
  .command("download <callId>")
3966
4049
  .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)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt",
3
- "version": "1.75.0",
3
+ "version": "1.77.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",