@getdial/cli 0.37.1 → 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 +33 -3
- package/dist/commands/group/list.js +28 -0
- package/dist/commands/message/list.js +5 -1
- package/dist/commands/message/send.js +21 -1
- package/dist/commands/number/purchase.js +8 -0
- package/dist/commands/number/whatsapp.js +26 -0
- package/dist/commands/typing/start.js +14 -0
- package/dist/commands/typing/stop.js +6 -0
- package/dist/lib/ops/groups.js +16 -0
- package/dist/lib/ops/messages.js +24 -6
- package/dist/lib/ops/numbers.js +44 -0
- package/dist/lib/ops/typing.js +7 -1
- package/dist/lib/versioned-file.js +9 -1
- package/dist/mcp/schemas.js +25 -2
- package/dist/mcp/tools/index.js +2 -0
- package/dist/mcp/tools/list-groups.js +20 -0
- package/dist/mcp/tools/list-messages.js +8 -1
- package/dist/mcp/tools/send-message.js +20 -2
- package/dist/mcp/tools/start-typing.js +5 -0
- package/dist/mcp/tools/stop-typing.js +5 -0
- package/dist/mcp/tools/tool-names.js +54 -0
- package/package.json +1 -1
- package/skills.tar.gz +0 -0
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
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/lib/ops/messages.js
CHANGED
|
@@ -46,20 +46,31 @@ function readMediaFile(path) {
|
|
|
46
46
|
}
|
|
47
47
|
export async function sendMessage(opts) {
|
|
48
48
|
const auth = maybeAuth();
|
|
49
|
-
|
|
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
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
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
|
-
|
|
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)
|
package/dist/lib/ops/numbers.js
CHANGED
|
@@ -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)
|
package/dist/lib/ops/typing.js
CHANGED
|
@@ -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", {
|
|
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;
|
|
@@ -2,6 +2,14 @@ import { chmodSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, w
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { logger } from "./log.js";
|
|
4
4
|
const CHMOD_UNSUPPORTED_CODES = new Set(["ENOTSUP", "EOPNOTSUPP", "EPERM"]);
|
|
5
|
+
/**
|
|
6
|
+
* Windows has no POSIX permission bits: Node synthesizes mode 0o666 (or 0o444
|
|
7
|
+
* for read-only files) regardless of the file's ACL, and chmod can only toggle
|
|
8
|
+
* the read-only flag. Enforcing `secure` there would reject every file we just
|
|
9
|
+
* wrote ourselves, so access control falls to the ACL on the per-user state
|
|
10
|
+
* directory instead.
|
|
11
|
+
*/
|
|
12
|
+
const hasPosixModes = () => process.platform !== "win32";
|
|
5
13
|
function ensureDir(dir) {
|
|
6
14
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
7
15
|
try {
|
|
@@ -41,7 +49,7 @@ export function defineVersionedFile(opts) {
|
|
|
41
49
|
/** v0 is the legacy unversioned `<base>.json`. */
|
|
42
50
|
const filePath = (version) => join(opts.dir(), version === 0 ? `${opts.base}.json` : `${opts.base}.v${version}.json`);
|
|
43
51
|
function parseAt(path) {
|
|
44
|
-
if (opts.secure) {
|
|
52
|
+
if (opts.secure && hasPosixModes()) {
|
|
45
53
|
const fileMode = statSync(path).mode & 0o777;
|
|
46
54
|
if (fileMode & 0o077) {
|
|
47
55
|
throw new Error(`${path} has insecure permissions (mode ${fileMode.toString(8)})`);
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -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
|
|
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(),
|
package/dist/mcp/tools/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
package/skills.tar.gz
CHANGED
|
Binary file
|