@getdial/cli 0.37.2 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -16,6 +16,8 @@ import { runWaitFor } from "./commands/wait-for.js";
16
16
  import { runNumberList } from "./commands/number/list.js";
17
17
  import { runNumberPurchase } from "./commands/number/purchase.js";
18
18
  import { runNumberSet } from "./commands/number/set.js";
19
+ import { runNumberWhatsapp } from "./commands/number/whatsapp.js";
20
+ import { runGroupList } from "./commands/group/list.js";
19
21
  import { runMessageSend } from "./commands/message/send.js";
20
22
  import { runMessageReply } from "./commands/message/reply.js";
21
23
  import { runMessageList } from "./commands/message/list.js";
@@ -163,6 +165,7 @@ number
163
165
  .option("--inbound-language <bcp47>", "language tag inbound calls are pinned to (default: detect from the caller's country prefix, alongside en-US)")
164
166
  .option("--area-code <code>", "preferred US area code (only US numbers can be provisioned; ignored with --include-imessage)")
165
167
  .option("--include-imessage", "provision an iMessage number (pay-as-you-go only; provisioned asynchronously — poll `dial number list` until ready)")
168
+ .option("--whatsapp", "also connect WhatsApp to the new line (beta, enabled per account). Requires --include-imessage: WhatsApp is a channel on an iMessage line")
166
169
  .option("--json", "machine-readable output")
167
170
  .action(async (opts) => process.exit(await runNumberPurchase({
168
171
  inboundInstruction: opts.inboundInstruction,
@@ -171,8 +174,14 @@ number
171
174
  inboundLanguage: opts.inboundLanguage,
172
175
  areaCode: opts.areaCode,
173
176
  includeImessage: !!opts.includeImessage,
177
+ whatsapp: !!opts.whatsapp,
174
178
  json: !!opts.json,
175
179
  })));
180
+ number
181
+ .command("whatsapp <number>")
182
+ .description("Connect WhatsApp to a number you already hold (beta, enabled per account). POST /api/v1/numbers/<id>/whatsapp.")
183
+ .option("--json", "machine-readable output")
184
+ .action(async (number, opts) => process.exit(await runNumberWhatsapp({ number, json: !!opts.json })));
176
185
  number
177
186
  .command("set <number>")
178
187
  .description("Update a number's properties (at least one flag). PATCH /api/v1/numbers/<id>.")
@@ -192,6 +201,8 @@ number
192
201
  .option("--first-name <text>", 'iMessage display first name shown beside this number\'s messages (iMessage numbers only); pass "" to clear')
193
202
  .option("--last-name <text>", 'iMessage display last name (iMessage numbers only); pass "" to clear')
194
203
  .option("--avatar <path-or-url>", "iMessage avatar photo (iMessage numbers only): a local image file (jpeg/png/gif/webp, max 5 MB) to upload, or a public image URL to fetch. Replaces the current photo; photos can't be removed")
204
+ .option("--whatsapp-name <text>", "WhatsApp display name shown to recipients (WhatsApp-ready numbers only): 1-25 chars, no reserved marks. The call blocks until WhatsApp applies it")
205
+ .option("--whatsapp-avatar <path-or-url>", "WhatsApp avatar photo (WhatsApp-ready numbers only): a local image file or public URL. Square jpeg/png between 192x192 and 640x640 (not resized)")
195
206
  .option("--json", "machine-readable output")
196
207
  .action(async (numberArg, opts) => {
197
208
  let maxCallDurationSeconds;
@@ -211,13 +222,17 @@ number
211
222
  firstName: opts.firstName,
212
223
  lastName: opts.lastName,
213
224
  avatar: opts.avatar,
225
+ whatsappName: opts.whatsappName,
226
+ whatsappAvatar: opts.whatsappAvatar,
214
227
  json: !!opts.json,
215
228
  }));
216
229
  });
217
230
  const message = program
218
231
  .command("message")
219
- .description("Send an SMS, optionally with media (MMS). POST /api/v1/messages.")
232
+ .description("Send a message to a number or a group, optionally with media (MMS). POST /api/v1/messages.")
220
233
  .option("--to <e164>", "destination phone number, E.164 (e.g. +14155551234)")
234
+ .option("--group <id>", "send into a group conversation instead (see `dial group list`); the sending line comes from the group. Exclusive with --to")
235
+ .option("--channel <imessage|whatsapp>", "which channel to send on, for a line carrying both; omit to use the number's own default")
221
236
  .option("--body <text>", "message body")
222
237
  .option("--from-number <ref>", "number to send from: id, owned E.164, or nickname (defaults to onboard's number; exclusive with --from-number-id)")
223
238
  .option("--from-number-id <id>", "phoneNumberId to send from (defaults to onboard's number; exclusive with --from-number)")
@@ -225,8 +240,11 @@ const message = program
225
240
  .option("--force-audio-file", "send an audio attachment as a regular file attachment instead of an iMessage voice message")
226
241
  .option("--json", "machine-readable output")
227
242
  .action(async (opts) => {
228
- if (!opts.to) {
229
- console.error("error: --to is required to send a message. Use `dial message list` to list, or `dial message --help` for usage.");
243
+ // A destination is still required — it just has two forms now. The exactly-one
244
+ // rule itself lives in runMessageSend, so the MCP tool and the verb enforce it
245
+ // from one place; this only keeps the familiar message for the common mistake.
246
+ if (!opts.to && !opts.group) {
247
+ console.error("error: --to or --group is required to send a message. Use `dial message list` to list, `dial group list` for your groups, or `dial message --help` for usage.");
230
248
  process.exit(2);
231
249
  }
232
250
  if (!opts.body && (opts.media ?? []).length === 0) {
@@ -235,6 +253,8 @@ const message = program
235
253
  }
236
254
  process.exit(await runMessageSend({
237
255
  to: opts.to,
256
+ group: opts.group,
257
+ channel: opts.channel,
238
258
  body: opts.body,
239
259
  fromNumber: opts.fromNumber,
240
260
  fromNumberId: opts.fromNumberId,
@@ -265,15 +285,25 @@ message
265
285
  .command("list")
266
286
  .description("List recent messages on your account. GET /api/v1/messages.")
267
287
  .option("--number-id <id>", "filter to a single phone number")
288
+ .option("--group <id>", "filter to one group conversation (see `dial group list`)")
268
289
  .option("--direction <dir>", "inbound or outbound")
269
290
  .option("--since <iso8601>", "only messages created after this timestamp")
270
291
  .option("--json", "machine-readable output")
271
292
  .action(async (opts) => process.exit(await runMessageList({
272
293
  numberId: opts.numberId,
294
+ group: opts.group,
273
295
  direction: opts.direction,
274
296
  since: opts.since,
275
297
  json: !!opts.json,
276
298
  })));
299
+ const group = program
300
+ .command("group")
301
+ .description("Group conversations your lines are in (WhatsApp).");
302
+ group
303
+ .command("list")
304
+ .description("List the group conversations your lines are in. GET /api/v1/groups.")
305
+ .option("--json", "machine-readable output")
306
+ .action(async (opts) => process.exit(await runGroupList({ json: !!opts.json })));
277
307
  const typing = program
278
308
  .command("typing")
279
309
  .description("Show or clear a typing indicator. iMessage numbers display it; SMS numbers ignore it. POST /api/v1/typing.");
@@ -282,6 +312,7 @@ typing
282
312
  .description("Show a typing indicator to a recipient, as if composing a message from your number.")
283
313
  .option("--to-number <e164>", "recipient phone number, E.164 (e.g. +14155551234)")
284
314
  .option("--from-number <ref>", "number the indicator appears from: id, owned E.164, or nickname (defaults to onboard's number)")
315
+ .option("--channel <imessage|whatsapp>", "which channel to show it on, for a line carrying both; omit to use the number's own default. Typing inside a group isn't supported")
285
316
  .option("--json", "machine-readable output")
286
317
  .action(async (opts) => {
287
318
  if (!opts.toNumber) {
@@ -291,6 +322,7 @@ typing
291
322
  process.exit(await runTypingStart({
292
323
  toNumber: opts.toNumber,
293
324
  fromNumber: opts.fromNumber,
325
+ channel: opts.channel,
294
326
  json: !!opts.json,
295
327
  }));
296
328
  });
@@ -299,6 +331,7 @@ typing
299
331
  .description("Clear a typing indicator previously shown with `typing start`.")
300
332
  .option("--to-number <e164>", "recipient phone number, E.164 (e.g. +14155551234)")
301
333
  .option("--from-number <ref>", "number the indicator appears from: id, owned E.164, or nickname (defaults to onboard's number)")
334
+ .option("--channel <imessage|whatsapp>", "which channel to clear it on; pass the same channel `typing start` was given")
302
335
  .option("--json", "machine-readable output")
303
336
  .action(async (opts) => {
304
337
  if (!opts.toNumber) {
@@ -308,6 +341,7 @@ typing
308
341
  process.exit(await runTypingStop({
309
342
  toNumber: opts.toNumber,
310
343
  fromNumber: opts.fromNumber,
344
+ channel: opts.channel,
311
345
  json: !!opts.json,
312
346
  }));
313
347
  });
@@ -0,0 +1,28 @@
1
+ import { listGroups } from "../../lib/ops/groups.js";
2
+ import { isDialError } from "../../lib/ops/errors.js";
3
+ import { printDialError } from "../../lib/cli-error.js";
4
+ export async function runGroupList(opts) {
5
+ try {
6
+ const groups = await listGroups();
7
+ if (opts.json) {
8
+ console.log(JSON.stringify({ ok: true, groups }));
9
+ return 0;
10
+ }
11
+ if (groups.length === 0) {
12
+ console.log("no groups. a group appears here once one of your lines is added to it.");
13
+ return 0;
14
+ }
15
+ for (const g of groups) {
16
+ // A name Dial could not read is rendered as a dash — never the literal "null",
17
+ // and never the group id standing in for a name, which would read as one.
18
+ const name = g.name ?? "—";
19
+ console.log(`${g.id} ${name}`);
20
+ }
21
+ return 0;
22
+ }
23
+ catch (e) {
24
+ if (isDialError(e))
25
+ return printDialError(opts.json, e);
26
+ throw e;
27
+ }
28
+ }
@@ -5,6 +5,7 @@ export async function runMessageList(opts) {
5
5
  try {
6
6
  const messages = await listMessages({
7
7
  numberId: opts.numberId,
8
+ groupId: opts.group,
8
9
  direction: opts.direction,
9
10
  since: opts.since,
10
11
  });
@@ -18,7 +19,10 @@ export async function runMessageList(opts) {
18
19
  }
19
20
  for (const m of messages) {
20
21
  const mediaTag = m.media && m.media.length > 0 ? ` [${m.media.length} media]` : "";
21
- console.log(`${m.createdAt} ${(m.direction ?? "").padEnd(8)} ${m.from} -> ${m.to} ${m.body}${mediaTag}`);
22
+ // A group message has no `to`: the destination is the group. Naming it keeps the
23
+ // column meaningful instead of printing an empty slot.
24
+ const destination = m.to ?? `group ${m.groupId}`;
25
+ console.log(`${m.createdAt} ${(m.direction ?? "").padEnd(8)} ${m.from} -> ${destination} ${m.body}${mediaTag}`);
22
26
  }
23
27
  return 0;
24
28
  }
@@ -1,13 +1,28 @@
1
1
  import { sendMessage } from "../../lib/ops/messages.js";
2
2
  import { isDialError } from "../../lib/ops/errors.js";
3
3
  import { printDialError } from "../../lib/cli-error.js";
4
+ /** The channels the API accepts. Checked locally so a typo never becomes a 400. */
5
+ export const CHANNELS = ["imessage", "whatsapp"];
4
6
  export async function runMessageSend(opts) {
7
+ // Both destination checks happen BEFORE any HTTP call: a caller who gave two
8
+ // destinations, or none, gets told what to fix rather than a server rejection
9
+ // they have to map back to their own flags.
10
+ if ((opts.to === undefined) === (opts.group === undefined)) {
11
+ console.error("error: provide exactly one of --to and --group.");
12
+ return 2;
13
+ }
14
+ if (opts.channel !== undefined && !CHANNELS.includes(opts.channel)) {
15
+ console.error(`error: --channel must be one of ${CHANNELS.join(", ")}.`);
16
+ return 2;
17
+ }
5
18
  try {
6
19
  const m = await sendMessage({
7
20
  to: opts.to,
21
+ groupId: opts.group,
8
22
  body: opts.body,
9
23
  fromNumber: opts.fromNumber,
10
24
  fromNumberId: opts.fromNumberId,
25
+ channel: opts.channel,
11
26
  media: opts.media,
12
27
  forceAudioFile: opts.forceAudioFile,
13
28
  });
@@ -18,7 +33,12 @@ export async function runMessageSend(opts) {
18
33
  console.log(`sent.`);
19
34
  console.log(` channel: ${m.channel}`);
20
35
  console.log(` from: ${m.from}`);
21
- console.log(` to: ${m.to}`);
36
+ // A group message has no `to` — the destination is the group — so print the
37
+ // group instead of an empty line that reads like a bug.
38
+ if (m.groupId)
39
+ console.log(` group: ${m.groupId}`);
40
+ else
41
+ console.log(` to: ${m.to}`);
22
42
  console.log(` body: ${m.body}`);
23
43
  for (const item of m.media ?? []) {
24
44
  console.log(` media: ${item.url} (${item.contentType})`);
@@ -2,6 +2,13 @@ import { purchaseNumber } from "../../lib/ops/numbers.js";
2
2
  import { isDialError } from "../../lib/ops/errors.js";
3
3
  import { printDialError } from "../../lib/cli-error.js";
4
4
  export async function runNumberPurchase(opts) {
5
+ // WhatsApp rides on an iMessage line, so there is no standard-number combination to
6
+ // ask for. Refused here, before spending anything, and naming the requirement rather
7
+ // than relaying a 400 the caller has to map back to their flags.
8
+ if (opts.whatsapp && !opts.includeImessage) {
9
+ console.error("error: --whatsapp requires --include-imessage (WhatsApp is a channel on an iMessage line).");
10
+ return 2;
11
+ }
5
12
  try {
6
13
  const n = await purchaseNumber({
7
14
  inboundInstruction: opts.inboundInstruction,
@@ -10,6 +17,7 @@ export async function runNumberPurchase(opts) {
10
17
  inboundLanguage: opts.inboundLanguage,
11
18
  areaCode: opts.areaCode,
12
19
  includeImessage: opts.includeImessage,
20
+ whatsapp: opts.whatsapp,
13
21
  });
14
22
  if (opts.json) {
15
23
  console.log(JSON.stringify({ ok: true, number: n }));
@@ -17,6 +17,8 @@ export async function runNumberSet(opts) {
17
17
  ...(opts.firstName !== undefined ? { firstName: opts.firstName } : {}),
18
18
  ...(opts.lastName !== undefined ? { lastName: opts.lastName } : {}),
19
19
  ...(opts.avatar !== undefined ? { avatar: opts.avatar } : {}),
20
+ ...(opts.whatsappName !== undefined ? { whatsappName: opts.whatsappName } : {}),
21
+ ...(opts.whatsappAvatar !== undefined ? { whatsappAvatar: opts.whatsappAvatar } : {}),
20
22
  });
21
23
  if (opts.json) {
22
24
  console.log(JSON.stringify({ ok: true, number: n }));
@@ -34,6 +36,10 @@ export async function runNumberSet(opts) {
34
36
  console.log(` display name: ${[n.firstName, n.lastName].filter(Boolean).join(" ")}`);
35
37
  console.log(` avatar: ${n.avatarUrl ?? ""}`);
36
38
  }
39
+ if (n.whatsappName != null || n.whatsappAvatarUrl != null) {
40
+ console.log(` whatsapp name: ${n.whatsappName ?? ""}`);
41
+ console.log(` whatsapp avatar: ${n.whatsappAvatarUrl ?? ""}`);
42
+ }
37
43
  }
38
44
  return 0;
39
45
  }
@@ -0,0 +1,26 @@
1
+ import { addWhatsappToNumber, resolveNumberId } from "../../lib/ops/numbers.js";
2
+ import { isDialError } from "../../lib/ops/errors.js";
3
+ import { printDialError } from "../../lib/cli-error.js";
4
+ export async function runNumberWhatsapp(opts) {
5
+ try {
6
+ const id = await resolveNumberId(opts.number);
7
+ const n = await addWhatsappToNumber(id);
8
+ if (opts.json) {
9
+ console.log(JSON.stringify({ ok: true, number: n }));
10
+ return 0;
11
+ }
12
+ console.log(`connecting WhatsApp.`);
13
+ console.log(` number: ${n.number}`);
14
+ console.log(` id: ${n.id}`);
15
+ // The track's own status, not the number's: they are independent, and it is this
16
+ // one the caller is waiting on.
17
+ console.log(` whatsapp: ${n.whatsapp?.status ?? "provisioning"}`);
18
+ console.log(`\nsetup runs in the background. poll \`dial number list\` until whatsapp is ready.`);
19
+ return 0;
20
+ }
21
+ catch (e) {
22
+ if (isDialError(e))
23
+ return printDialError(opts.json, e);
24
+ throw e;
25
+ }
26
+ }
@@ -1,12 +1,26 @@
1
1
  import { setTyping } from "../../lib/ops/typing.js";
2
2
  import { isDialError } from "../../lib/ops/errors.js";
3
3
  import { printDialError } from "../../lib/cli-error.js";
4
+ /** The channels the API accepts. Checked locally so a typo never becomes a 400. */
5
+ export const TYPING_CHANNELS = ["imessage", "whatsapp"];
6
+ /**
7
+ * Validate `--channel` before any request. Shared by start and stop so the two
8
+ * cannot drift into accepting different words for the same thing.
9
+ */
10
+ export function invalidChannel(channel) {
11
+ return (channel !== undefined && !TYPING_CHANNELS.includes(channel));
12
+ }
4
13
  export async function runTypingStart(opts) {
14
+ if (invalidChannel(opts.channel)) {
15
+ console.error(`error: --channel must be one of ${TYPING_CHANNELS.join(", ")}.`);
16
+ return 2;
17
+ }
5
18
  try {
6
19
  const result = await setTyping({
7
20
  toNumber: opts.toNumber,
8
21
  value: true,
9
22
  fromNumber: opts.fromNumber,
23
+ channel: opts.channel,
10
24
  });
11
25
  if (opts.json) {
12
26
  console.log(JSON.stringify(result));
@@ -1,12 +1,18 @@
1
1
  import { setTyping } from "../../lib/ops/typing.js";
2
2
  import { isDialError } from "../../lib/ops/errors.js";
3
3
  import { printDialError } from "../../lib/cli-error.js";
4
+ import { invalidChannel, TYPING_CHANNELS } from "./start.js";
4
5
  export async function runTypingStop(opts) {
6
+ if (invalidChannel(opts.channel)) {
7
+ console.error(`error: --channel must be one of ${TYPING_CHANNELS.join(", ")}.`);
8
+ return 2;
9
+ }
5
10
  try {
6
11
  const result = await setTyping({
7
12
  toNumber: opts.toNumber,
8
13
  value: false,
9
14
  fromNumber: opts.fromNumber,
15
+ channel: opts.channel,
10
16
  });
11
17
  if (opts.json) {
12
18
  console.log(JSON.stringify(result));
@@ -0,0 +1,16 @@
1
+ import { apiGet } from "../api.js";
2
+ import { maybeAuth } from "./auth.js";
3
+ import { DialError } from "./errors.js";
4
+ /**
5
+ * The group conversations the account's lines are in.
6
+ *
7
+ * Shared by `dial group list` and the local MCP `list_groups` tool, so both speak to
8
+ * the API through one place and inherit the saved key the same way.
9
+ */
10
+ export async function listGroups() {
11
+ const auth = maybeAuth();
12
+ const res = await apiGet("/api/v1/groups", auth?.apiKey);
13
+ if (!res.ok)
14
+ throw new DialError("list_failed", res.error, res.status);
15
+ return res.data.groups ?? [];
16
+ }
@@ -46,20 +46,31 @@ function readMediaFile(path) {
46
46
  }
47
47
  export async function sendMessage(opts) {
48
48
  const auth = maybeAuth();
49
- const from = resolveFromSelector(auth, opts);
49
+ // A group already belongs to one of the account's lines, so a group send needs no
50
+ // from-number — and must not inherit the SAVED DEFAULT one, because the server
51
+ // refuses a from-number that disagrees with the group. Inheriting it would turn
52
+ // the onboarding convenience into a failed send. An explicitly passed one is still
53
+ // forwarded, and still checked server-side.
54
+ const explicitFrom = opts.fromNumber !== undefined || opts.fromNumberId !== undefined;
55
+ const from = opts.groupId && !explicitFrom ? {} : resolveFromSelector(auth, opts);
50
56
  const media = opts.media ?? [];
51
57
  if (media.length > MAX_MEDIA_ITEMS) {
52
58
  throw new DialError("too_much_media", `at most ${MAX_MEDIA_ITEMS} media items are allowed per message (got ${media.length})`);
53
59
  }
54
- // No `channel`: the server determines it from the from-number (a standard number
55
- // sends SMS; an iMessage number sends iMessage with RCS/SMS fallback) and its send
56
- // schema is strictsending a stale `channel` field is rejected as a 400.
60
+ // `channel` is sent only when the caller named one. Omitted, the server uses the
61
+ // from-number's own default (a standard number sends SMS; an iMessage number sends
62
+ // iMessage with RCS/SMS fallback) and the send schema is strict, so an empty or
63
+ // stale field would be a 400 rather than a no-op.
64
+ // Each destination likewise appears only when given: the server enforces the
65
+ // to/groupId XOR, and a key present-but-empty reads as a second destination.
57
66
  // URLs-only goes as plain JSON; any local file switches to multipart.
58
67
  const hasFiles = media.some((m) => !isHttpUrl(m));
59
68
  let res;
60
69
  if (!hasFiles) {
61
70
  res = await apiPost("/api/v1/messages", {
62
- to: opts.to,
71
+ ...(opts.to !== undefined ? { to: opts.to } : {}),
72
+ ...(opts.groupId !== undefined ? { groupId: opts.groupId } : {}),
73
+ ...(opts.channel !== undefined ? { channel: opts.channel } : {}),
63
74
  ...(opts.body ? { body: opts.body } : {}),
64
75
  ...from,
65
76
  ...(media.length ? { mediaUrls: media } : {}),
@@ -68,7 +79,12 @@ export async function sendMessage(opts) {
68
79
  }
69
80
  else {
70
81
  const form = new ApiFormData();
71
- form.set("to", opts.to);
82
+ if (opts.to !== undefined)
83
+ form.set("to", opts.to);
84
+ if (opts.groupId !== undefined)
85
+ form.set("groupId", opts.groupId);
86
+ if (opts.channel !== undefined)
87
+ form.set("channel", opts.channel);
72
88
  if (opts.body)
73
89
  form.set("body", opts.body);
74
90
  for (const [field, value] of Object.entries(from))
@@ -95,6 +111,8 @@ export async function listMessages(opts) {
95
111
  const params = new URLSearchParams();
96
112
  if (opts.numberId)
97
113
  params.set("numberId", opts.numberId);
114
+ if (opts.groupId)
115
+ params.set("groupId", opts.groupId);
98
116
  if (opts.direction)
99
117
  params.set("direction", opts.direction);
100
118
  if (opts.since)
@@ -51,11 +51,55 @@ export async function purchaseNumber(opts) {
51
51
  body.capabilities = ["sms", "call", "imessage"];
52
52
  else if (opts.areaCode)
53
53
  body.areaCode = opts.areaCode;
54
+ if (opts.whatsapp)
55
+ body.whatsapp = true;
54
56
  const res = await apiPost("/api/v1/numbers", body, auth?.apiKey);
55
57
  if (!res.ok)
56
58
  throw new DialError("purchase_failed", res.error, res.status);
57
59
  return res.data.number;
58
60
  }
61
+ /**
62
+ * Resolve a number reference — an id, an owned E.164, or a nickname — to its id.
63
+ *
64
+ * The REST API keys numbers by id while every CLI verb takes whichever form the caller
65
+ * has to hand, so one lookup serves them all. An id is returned as given when it
66
+ * matches a number the account owns, which also keeps a copy-pasted id from a previous
67
+ * `dial number list` working.
68
+ */
69
+ export async function resolveNumberId(ref) {
70
+ const auth = maybeAuth();
71
+ const list = await apiGet("/api/v1/numbers", auth?.apiKey);
72
+ if (!list.ok)
73
+ throw new DialError("list_failed", list.error, list.status);
74
+ const match = list.data.numbers.find((n) => n.id === ref || n.number === ref || n.nickname === ref);
75
+ if (!match) {
76
+ const known = list.data.numbers.map((n) => n.number).join(", ") || "(none)";
77
+ throw new DialError("number_not_found", `No phone number ${ref} on your account. Yours: ${known}.`);
78
+ }
79
+ return match.id;
80
+ }
81
+ /** Copy for the 404 both WhatsApp provisioning paths answer without beta access. */
82
+ export const WHATSAPP_NOT_ENABLED = "WhatsApp is in beta and isn't enabled for this account. " +
83
+ "See https://docs.getdial.ai/documentation/capabilities/whatsapp to request access.";
84
+ /**
85
+ * Connect WhatsApp to a number the account already holds
86
+ * (POST /api/v1/numbers/{id}/whatsapp).
87
+ *
88
+ * A 404 means one of two things — no such number, or no beta access — and the API
89
+ * cannot distinguish them by design: to an account without access the endpoint does
90
+ * not exist. Reported as the access message, because "not found" on a number the
91
+ * caller just listed reads as a typo and sends them looking in the wrong place.
92
+ */
93
+ export async function addWhatsappToNumber(numberId) {
94
+ const auth = maybeAuth();
95
+ const res = await apiPost(`/api/v1/numbers/${encodeURIComponent(numberId)}/whatsapp`, {}, auth?.apiKey);
96
+ if (!res.ok) {
97
+ if (res.status === 404)
98
+ throw new DialError("whatsapp_unavailable", WHATSAPP_NOT_ENABLED, 404);
99
+ throw new DialError("whatsapp_failed", res.error, res.status);
100
+ }
101
+ return res.data.number;
102
+ }
59
103
  export async function setNumberProperties(opts) {
60
104
  const body = {};
61
105
  if (opts.inboundInstruction !== undefined)
@@ -73,14 +117,21 @@ export async function setNumberProperties(opts) {
73
117
  body.firstName = opts.firstName;
74
118
  if (opts.lastName !== undefined)
75
119
  body.lastName = opts.lastName;
120
+ if (opts.whatsappName !== undefined)
121
+ body.whatsappName = opts.whatsappName;
76
122
  // A URL avatar goes in the JSON body; a local file forces multipart (below).
77
123
  // Read + validate the file up front, before any API round-trip, so a bad
78
124
  // path or unsupported type fails fast.
79
125
  const avatarFile = opts.avatar !== undefined && !isHttpUrl(opts.avatar) ? readAvatarFile(opts.avatar) : null;
80
126
  if (opts.avatar !== undefined && !avatarFile)
81
127
  body.avatarUrl = opts.avatar;
82
- if (Object.keys(body).length === 0 && !avatarFile) {
83
- throw new DialError("bad_request", "Provide at least one property to update (inboundInstruction, inboundVoiceGender, inboundLanguage, nickname, maxCallDurationSeconds, firstName, lastName, or avatar).");
128
+ const whatsappAvatarFile = opts.whatsappAvatar !== undefined && !isHttpUrl(opts.whatsappAvatar)
129
+ ? readAvatarFile(opts.whatsappAvatar)
130
+ : null;
131
+ if (opts.whatsappAvatar !== undefined && !whatsappAvatarFile)
132
+ body.whatsappAvatarUrl = opts.whatsappAvatar;
133
+ if (Object.keys(body).length === 0 && !avatarFile && !whatsappAvatarFile) {
134
+ throw new DialError("bad_request", "Provide at least one property to update (inboundInstruction, inboundVoiceGender, inboundLanguage, nickname, maxCallDurationSeconds, firstName, lastName, avatar, whatsappName, or whatsappAvatar).");
84
135
  }
85
136
  const auth = maybeAuth();
86
137
  // The REST API keys numbers by id; the CLI/tool takes the E.164 number for ergonomics,
@@ -97,11 +148,18 @@ export async function setNumberProperties(opts) {
97
148
  // A local avatar file forces a multipart PATCH: every scalar field goes in as a
98
149
  // text part alongside the uploaded `avatar` file. Otherwise a plain JSON PATCH.
99
150
  let res;
100
- if (avatarFile) {
151
+ if (avatarFile || whatsappAvatarFile) {
101
152
  const form = new ApiFormData();
102
153
  for (const [field, value] of Object.entries(body))
103
154
  form.set(field, String(value));
104
- form.append("avatar", new Blob([new Uint8Array(avatarFile.data)], { type: avatarFile.contentType }), avatarFile.name);
155
+ if (avatarFile) {
156
+ form.append("avatar", new Blob([new Uint8Array(avatarFile.data)], { type: avatarFile.contentType }), avatarFile.name);
157
+ }
158
+ if (whatsappAvatarFile) {
159
+ form.append("whatsappAvatar", new Blob([new Uint8Array(whatsappAvatarFile.data)], {
160
+ type: whatsappAvatarFile.contentType,
161
+ }), whatsappAvatarFile.name);
162
+ }
105
163
  res = await apiPatchMultipart(path, form, auth?.apiKey);
106
164
  }
107
165
  else {
@@ -9,7 +9,13 @@ import { DialError } from "./errors.js";
9
9
  export async function setTyping(opts) {
10
10
  const auth = maybeAuth();
11
11
  const fromNumber = requireFromNumber(auth, opts.fromNumber);
12
- const res = await apiPost("/api/v1/typing", { toNumber: opts.toNumber, value: opts.value, fromNumber }, auth?.apiKey);
12
+ const res = await apiPost("/api/v1/typing", {
13
+ toNumber: opts.toNumber,
14
+ value: opts.value,
15
+ fromNumber,
16
+ // Only when named: the typing schema is strict, so an empty field is a 400.
17
+ ...(opts.channel !== undefined ? { channel: opts.channel } : {}),
18
+ }, auth?.apiKey);
13
19
  if (!res.ok)
14
20
  throw new DialError("typing_failed", res.error, res.status);
15
21
  return res.data;
@@ -53,13 +53,46 @@ export const phoneNumberSchema = z
53
53
  .nullable()
54
54
  .optional()
55
55
  .describe("URL of the number's iMessage avatar photo; null when unset or not an iMessage number"),
56
+ whatsappName: z
57
+ .string()
58
+ .nullable()
59
+ .optional()
60
+ .describe("WhatsApp display name; null on numbers without a WhatsApp track"),
61
+ whatsappAvatarUrl: z
62
+ .string()
63
+ .nullable()
64
+ .optional()
65
+ .describe("URL of the number's WhatsApp avatar; null when unset"),
56
66
  })
57
67
  .passthrough();
68
+ /**
69
+ * A group conversation. Mirrors the hosted server's `groupSchema` — `createdAt` is a
70
+ * string, never a z.date(), because a Date is unrepresentable in JSON Schema and one
71
+ * bad schema fails the whole `tools/list`.
72
+ */
73
+ export const groupSchema = z.object({
74
+ id: z.string().describe("Group id — pass as groupId to send_message or list_messages"),
75
+ name: z
76
+ .string()
77
+ .nullable()
78
+ .describe("The group's current name, or null when no line could report it in time"),
79
+ createdAt: z
80
+ .string()
81
+ .optional()
82
+ .describe("ISO-8601: when Dial first learned of this group (a join, or its first message)"),
83
+ });
58
84
  export const messageSchema = z
59
85
  .object({
60
86
  id: z.string(),
61
- from: z.string(),
62
- to: z.string(),
87
+ from: z.string().describe("Sender, E.164. On a group message, the participant who sent it"),
88
+ to: z
89
+ .string()
90
+ .nullable()
91
+ .describe("Recipient, E.164 — null on a group message, whose destination is groupId"),
92
+ groupId: z
93
+ .string()
94
+ .nullish()
95
+ .describe("The group this message belongs to, or null for a one-to-one conversation"),
63
96
  body: z.string(),
64
97
  channel: z.string().optional(),
65
98
  direction: z.string().optional(),
@@ -6,6 +6,7 @@ import { replyToMessageTool } from "./reply-to-message.js";
6
6
  import { startTypingTool } from "./start-typing.js";
7
7
  import { stopTypingTool } from "./stop-typing.js";
8
8
  import { listMessagesTool } from "./list-messages.js";
9
+ import { listGroupsTool } from "./list-groups.js";
9
10
  import { placeCallTool } from "./place-call.js";
10
11
  import { listCallsTool } from "./list-calls.js";
11
12
  import { getCallTool } from "./get-call.js";
@@ -31,6 +32,7 @@ export const tools = [
31
32
  startTypingTool,
32
33
  stopTypingTool,
33
34
  listMessagesTool,
35
+ listGroupsTool,
34
36
  placeCallTool,
35
37
  listCallsTool,
36
38
  getCallTool,
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ import { jsonResult } from "../result.js";
3
+ import { listGroups } from "../../lib/ops/groups.js";
4
+ import { groupSchema } from "../schemas.js";
5
+ export const listGroupsTool = {
6
+ name: "list_groups",
7
+ config: {
8
+ title: "List Groups",
9
+ description: "List the group conversations your lines are in. A group is a conversation that isn't a phone " +
10
+ "number, so it has an id of its own: pass it as groupId to send_message, or to list_messages to " +
11
+ "read that conversation. Groups exist on WhatsApp lines. " +
12
+ "A group's name can be null — it is read live from the line holding the conversation, so a line " +
13
+ "that can't answer in time yields a null name rather than hiding the group. " +
14
+ "There is no join event: list again to see a group your line was just added to.",
15
+ inputSchema: {},
16
+ outputSchema: { groups: z.array(groupSchema) },
17
+ annotations: { readOnlyHint: true, openWorldHint: true },
18
+ },
19
+ run: async () => jsonResult({ groups: await listGroups() }),
20
+ };
@@ -4,6 +4,10 @@ import { listMessages } from "../../lib/ops/messages.js";
4
4
  import { messageSchema } from "../schemas.js";
5
5
  const inputSchema = {
6
6
  numberId: z.string().optional().describe("Filter to a single phone number id"),
7
+ groupId: z
8
+ .string()
9
+ .optional()
10
+ .describe("Filter to one group conversation (see list_groups). Combines with the other filters"),
7
11
  direction: z.enum(["inbound", "outbound"]).optional().describe("Filter by direction"),
8
12
  since: z.string().optional().describe("Only messages created after this ISO-8601 timestamp"),
9
13
  };
@@ -11,7 +15,9 @@ export const listMessagesTool = {
11
15
  name: "list_messages",
12
16
  config: {
13
17
  title: "List Messages",
14
- description: "List recent messages on your account, newest first.",
18
+ description: "List recent messages on your account, newest first. Pass groupId to read one group " +
19
+ "conversation. On a group message `to` is null and the destination is groupId; which of your " +
20
+ "numbers the conversation is on is phoneNumberId.",
15
21
  inputSchema,
16
22
  outputSchema: { messages: z.array(messageSchema) },
17
23
  annotations: { readOnlyHint: true, openWorldHint: true },
@@ -19,6 +25,7 @@ export const listMessagesTool = {
19
25
  run: async (args) => jsonResult({
20
26
  messages: await listMessages({
21
27
  numberId: args.numberId,
28
+ groupId: args.groupId,
22
29
  direction: args.direction,
23
30
  since: args.since,
24
31
  }),
@@ -3,7 +3,20 @@ import { jsonResult } from "../result.js";
3
3
  import { sendMessage, MAX_MEDIA_ITEMS } from "../../lib/ops/messages.js";
4
4
  import { messageSchema } from "../schemas.js";
5
5
  const inputSchema = {
6
- to: z.string().min(7).describe("Destination phone number, E.164 (e.g. +14155550123)"),
6
+ to: z
7
+ .string()
8
+ .min(7)
9
+ .optional()
10
+ .describe("Destination phone number, E.164 (e.g. +14155550123). Provide exactly one of to or groupId"),
11
+ groupId: z
12
+ .string()
13
+ .min(1)
14
+ .optional()
15
+ .describe("A group conversation to send into (see list_groups), instead of a to number. The sending line comes from the group, so no from-number is needed. Provide exactly one of to or groupId"),
16
+ channel: z
17
+ .enum(["imessage", "whatsapp"])
18
+ .optional()
19
+ .describe("Which channel to send on, for a line that carries more than one. Omit to use the number's own default — a standard number sends SMS, an iMessage number sends iMessage. 'whatsapp' needs a line whose WhatsApp channel is ready; 'imessage' is refused on a number without an iMessage rail"),
7
20
  body: z
8
21
  .string()
9
22
  .optional()
@@ -31,7 +44,10 @@ export const sendMessageTool = {
31
44
  name: "send_message",
32
45
  config: {
33
46
  title: "Send message",
34
- description: "Send a message from one of your Dial numbers, optionally with media attachments (MMS). On an iMessage number, a single audio attachment is delivered as a voice message unless forceAudioFile is true.",
47
+ description: "Send a message from one of your Dial numbers to a phone number, or into a group conversation " +
48
+ "optionally with media attachments (MMS). On an iMessage number, a single audio attachment is " +
49
+ "delivered as a voice message unless forceAudioFile is true. " +
50
+ "Address it with exactly one of to or groupId. WhatsApp sends are text-only.",
35
51
  inputSchema,
36
52
  outputSchema: { message: messageSchema },
37
53
  annotations: { openWorldHint: true },
@@ -39,6 +55,8 @@ export const sendMessageTool = {
39
55
  run: async (args) => jsonResult({
40
56
  message: await sendMessage({
41
57
  to: args.to,
58
+ groupId: args.groupId,
59
+ channel: args.channel,
42
60
  body: args.body,
43
61
  fromNumber: args.fromNumber,
44
62
  fromNumberId: args.fromNumberId,
@@ -44,12 +44,21 @@ const inputSchema = {
44
44
  .url()
45
45
  .optional()
46
46
  .describe("Public image URL to set as the number's iMessage avatar photo (the server downloads it). jpeg/png/gif/webp, max 5 MB. iMessage numbers only. The photo can be replaced but not removed."),
47
+ whatsappName: z
48
+ .string()
49
+ .optional()
50
+ .describe("WhatsApp display name shown to recipients. 1-25 chars, no reserved verification marks. WhatsApp-ready numbers only; the call blocks until WhatsApp applies it."),
51
+ whatsappAvatarUrl: z
52
+ .string()
53
+ .url()
54
+ .optional()
55
+ .describe("Public image URL to set as the number's WhatsApp avatar (the server downloads it). Square jpeg or png between 192x192 and 640x640 (not resized). WhatsApp-ready numbers only."),
47
56
  };
48
57
  export const setNumberPropertiesTool = {
49
58
  name: "set_number_properties",
50
59
  config: {
51
60
  title: "Set Number Properties",
52
- description: "Update a phone number's properties: its inbound instruction (the system prompt for inbound calls), inbound voice gender, inbound language, nickname, and — for iMessage numbers — its display identity (firstName, lastName, avatarUrl shown beside its messages). Provide at least one.",
61
+ description: "Update a phone number's properties: its inbound instruction (the system prompt for inbound calls), inbound voice gender, inbound language, nickname, and — for iMessage numbers — its display identity (firstName, lastName, avatarUrl), and for WhatsApp-ready numbers its WhatsApp identity (whatsappName, whatsappAvatarUrl). Provide at least one.",
53
62
  inputSchema,
54
63
  outputSchema: { number: phoneNumberSchema },
55
64
  annotations: { openWorldHint: true },
@@ -69,6 +78,10 @@ export const setNumberPropertiesTool = {
69
78
  ? { maxCallDurationSeconds: args.maxCallDurationSeconds }
70
79
  : {}),
71
80
  ...(args.firstName !== undefined ? { firstName: args.firstName } : {}),
81
+ ...(args.whatsappName !== undefined ? { whatsappName: args.whatsappName } : {}),
82
+ ...(args.whatsappAvatarUrl !== undefined
83
+ ? { whatsappAvatar: args.whatsappAvatarUrl }
84
+ : {}),
72
85
  ...(args.lastName !== undefined ? { lastName: args.lastName } : {}),
73
86
  ...(args.avatarUrl !== undefined ? { avatar: args.avatarUrl } : {}),
74
87
  }),
@@ -7,6 +7,10 @@ const inputSchema = {
7
7
  .string()
8
8
  .min(1)
9
9
  .describe("Number the indicator appears from: a phone number id, one of your numbers in E.164, or a nickname"),
10
+ channel: z
11
+ .enum(["imessage", "whatsapp"])
12
+ .optional()
13
+ .describe("Which channel to show it on, for a line that carries more than one. Omit to use the number's own default. Typing inside a group is not supported"),
10
14
  };
11
15
  export const startTypingTool = {
12
16
  name: "start_typing",
@@ -24,6 +28,7 @@ export const startTypingTool = {
24
28
  run: async (args) => jsonResult(await setTyping({
25
29
  toNumber: args.toNumber,
26
30
  fromNumber: args.fromNumber,
31
+ channel: args.channel,
27
32
  value: true,
28
33
  })),
29
34
  };
@@ -7,6 +7,10 @@ const inputSchema = {
7
7
  .string()
8
8
  .min(1)
9
9
  .describe("Number the indicator appears from: a phone number id, one of your numbers in E.164, or a nickname"),
10
+ channel: z
11
+ .enum(["imessage", "whatsapp"])
12
+ .optional()
13
+ .describe("Which channel to clear it on, for a line that carries more than one. Omit to use the number's own default. Pass the same channel start_typing was given"),
10
14
  };
11
15
  export const stopTypingTool = {
12
16
  name: "stop_typing",
@@ -22,6 +26,7 @@ export const stopTypingTool = {
22
26
  run: async (args) => jsonResult(await setTyping({
23
27
  toNumber: args.toNumber,
24
28
  fromNumber: args.fromNumber,
29
+ channel: args.channel,
25
30
  value: false,
26
31
  })),
27
32
  };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The tool names this server is expected to expose, split into the half it shares with
3
+ * the hosted server and the half only a local install can offer.
4
+ *
5
+ * There are THREE copies of the MCP tool surface, and nothing but a check keeps them in
6
+ * step:
7
+ *
8
+ * 1. the hosted Remote server (`frontend/src/lib/mcp/tools/tool-names.ts` — the twin
9
+ * of this file, whose contents must equal OPERATIONAL_TOOL_NAMES exactly, in the
10
+ * same order),
11
+ * 2. this registry,
12
+ * 3. the published Remote/Local matrix in `dial-docs`
13
+ * (`fern/docs/pages/integrations/tools/mcp.mdx`).
14
+ *
15
+ * A tool added to one and forgotten in another is the drift AGENTS.md forbids, and it is
16
+ * invisible in review because each file reads correctly on its own. Asserting the
17
+ * registry against these lists turns that into a failing build instead.
18
+ *
19
+ * If the two files ever disagree, the fix is the SERVER that is missing a tool — never
20
+ * an edited list.
21
+ */
22
+ export const OPERATIONAL_TOOL_NAMES = [
23
+ "get_account_status",
24
+ "list_numbers",
25
+ "purchase_number",
26
+ "set_number_properties",
27
+ "send_message",
28
+ "reply_to_message",
29
+ "start_typing",
30
+ "stop_typing",
31
+ "list_messages",
32
+ "list_groups",
33
+ "place_call",
34
+ "list_calls",
35
+ "get_call",
36
+ "wait_for_event",
37
+ ];
38
+ /**
39
+ * Verbs only the local server has: they touch this machine (onboarding, the listen
40
+ * daemon, local event fan-out), which is why the Local server is a strict superset of
41
+ * the Remote one rather than a different surface.
42
+ */
43
+ export const LOCAL_ONLY_TOOL_NAMES = [
44
+ "auth_login",
45
+ "auth_verify_otp",
46
+ "auth_register_number",
47
+ "listen_install",
48
+ "listen_uninstall",
49
+ "listen_status",
50
+ "add_url_target",
51
+ "add_command_target",
52
+ "remove_local_target",
53
+ "list_local_targets",
54
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getdial/cli",
3
- "version": "0.37.2",
3
+ "version": "0.39.0",
4
4
  "description": "Dial CLI — install, sign up, and run the local listen service.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/skills.tar.gz CHANGED
Binary file