@getdial/cli 0.37.2 → 0.38.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>.")
@@ -216,8 +225,10 @@ number
216
225
  });
217
226
  const message = program
218
227
  .command("message")
219
- .description("Send an SMS, optionally with media (MMS). POST /api/v1/messages.")
228
+ .description("Send a message to a number or a group, optionally with media (MMS). POST /api/v1/messages.")
220
229
  .option("--to <e164>", "destination phone number, E.164 (e.g. +14155551234)")
230
+ .option("--group <id>", "send into a group conversation instead (see `dial group list`); the sending line comes from the group. Exclusive with --to")
231
+ .option("--channel <imessage|whatsapp>", "which channel to send on, for a line carrying both; omit to use the number's own default")
221
232
  .option("--body <text>", "message body")
222
233
  .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
234
  .option("--from-number-id <id>", "phoneNumberId to send from (defaults to onboard's number; exclusive with --from-number)")
@@ -225,8 +236,11 @@ const message = program
225
236
  .option("--force-audio-file", "send an audio attachment as a regular file attachment instead of an iMessage voice message")
226
237
  .option("--json", "machine-readable output")
227
238
  .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.");
239
+ // A destination is still required — it just has two forms now. The exactly-one
240
+ // rule itself lives in runMessageSend, so the MCP tool and the verb enforce it
241
+ // from one place; this only keeps the familiar message for the common mistake.
242
+ if (!opts.to && !opts.group) {
243
+ 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
244
  process.exit(2);
231
245
  }
232
246
  if (!opts.body && (opts.media ?? []).length === 0) {
@@ -235,6 +249,8 @@ const message = program
235
249
  }
236
250
  process.exit(await runMessageSend({
237
251
  to: opts.to,
252
+ group: opts.group,
253
+ channel: opts.channel,
238
254
  body: opts.body,
239
255
  fromNumber: opts.fromNumber,
240
256
  fromNumberId: opts.fromNumberId,
@@ -265,15 +281,25 @@ message
265
281
  .command("list")
266
282
  .description("List recent messages on your account. GET /api/v1/messages.")
267
283
  .option("--number-id <id>", "filter to a single phone number")
284
+ .option("--group <id>", "filter to one group conversation (see `dial group list`)")
268
285
  .option("--direction <dir>", "inbound or outbound")
269
286
  .option("--since <iso8601>", "only messages created after this timestamp")
270
287
  .option("--json", "machine-readable output")
271
288
  .action(async (opts) => process.exit(await runMessageList({
272
289
  numberId: opts.numberId,
290
+ group: opts.group,
273
291
  direction: opts.direction,
274
292
  since: opts.since,
275
293
  json: !!opts.json,
276
294
  })));
295
+ const group = program
296
+ .command("group")
297
+ .description("Group conversations your lines are in (WhatsApp).");
298
+ group
299
+ .command("list")
300
+ .description("List the group conversations your lines are in. GET /api/v1/groups.")
301
+ .option("--json", "machine-readable output")
302
+ .action(async (opts) => process.exit(await runGroupList({ json: !!opts.json })));
277
303
  const typing = program
278
304
  .command("typing")
279
305
  .description("Show or clear a typing indicator. iMessage numbers display it; SMS numbers ignore it. POST /api/v1/typing.");
@@ -282,6 +308,7 @@ typing
282
308
  .description("Show a typing indicator to a recipient, as if composing a message from your number.")
283
309
  .option("--to-number <e164>", "recipient phone number, E.164 (e.g. +14155551234)")
284
310
  .option("--from-number <ref>", "number the indicator appears from: id, owned E.164, or nickname (defaults to onboard's number)")
311
+ .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
312
  .option("--json", "machine-readable output")
286
313
  .action(async (opts) => {
287
314
  if (!opts.toNumber) {
@@ -291,6 +318,7 @@ typing
291
318
  process.exit(await runTypingStart({
292
319
  toNumber: opts.toNumber,
293
320
  fromNumber: opts.fromNumber,
321
+ channel: opts.channel,
294
322
  json: !!opts.json,
295
323
  }));
296
324
  });
@@ -299,6 +327,7 @@ typing
299
327
  .description("Clear a typing indicator previously shown with `typing start`.")
300
328
  .option("--to-number <e164>", "recipient phone number, E.164 (e.g. +14155551234)")
301
329
  .option("--from-number <ref>", "number the indicator appears from: id, owned E.164, or nickname (defaults to onboard's number)")
330
+ .option("--channel <imessage|whatsapp>", "which channel to clear it on; pass the same channel `typing start` was given")
302
331
  .option("--json", "machine-readable output")
303
332
  .action(async (opts) => {
304
333
  if (!opts.toNumber) {
@@ -308,6 +337,7 @@ typing
308
337
  process.exit(await runTypingStop({
309
338
  toNumber: opts.toNumber,
310
339
  fromNumber: opts.fromNumber,
340
+ channel: opts.channel,
311
341
  json: !!opts.json,
312
342
  }));
313
343
  });
@@ -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 }));
@@ -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)
@@ -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;
@@ -55,11 +55,34 @@ export const phoneNumberSchema = z
55
55
  .describe("URL of the number's iMessage avatar photo; null when unset or not an iMessage number"),
56
56
  })
57
57
  .passthrough();
58
+ /**
59
+ * A group conversation. Mirrors the hosted server's `groupSchema` — `createdAt` is a
60
+ * string, never a z.date(), because a Date is unrepresentable in JSON Schema and one
61
+ * bad schema fails the whole `tools/list`.
62
+ */
63
+ export const groupSchema = z.object({
64
+ id: z.string().describe("Group id — pass as groupId to send_message or list_messages"),
65
+ name: z
66
+ .string()
67
+ .nullable()
68
+ .describe("The group's current name, or null when no line could report it in time"),
69
+ createdAt: z
70
+ .string()
71
+ .optional()
72
+ .describe("ISO-8601: when Dial first learned of this group (a join, or its first message)"),
73
+ });
58
74
  export const messageSchema = z
59
75
  .object({
60
76
  id: z.string(),
61
- from: z.string(),
62
- to: z.string(),
77
+ from: z.string().describe("Sender, E.164. On a group message, the participant who sent it"),
78
+ to: z
79
+ .string()
80
+ .nullable()
81
+ .describe("Recipient, E.164 — null on a group message, whose destination is groupId"),
82
+ groupId: z
83
+ .string()
84
+ .nullish()
85
+ .describe("The group this message belongs to, or null for a one-to-one conversation"),
63
86
  body: z.string(),
64
87
  channel: z.string().optional(),
65
88
  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,
@@ -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.38.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