@getdial/cli 0.26.0 → 0.28.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 +11 -3
- package/dist/commands/message/list.js +2 -1
- package/dist/commands/message/send.js +10 -1
- package/dist/lib/api.js +39 -20
- package/dist/lib/ops/messages.js +79 -2
- package/dist/mcp/tools/send-message.js +15 -4
- package/package.json +1 -1
- package/skills.tar.gz +0 -0
package/dist/cli.js
CHANGED
|
@@ -147,20 +147,28 @@ number
|
|
|
147
147
|
});
|
|
148
148
|
const message = program
|
|
149
149
|
.command("message")
|
|
150
|
-
.description("Send an SMS. POST /api/v1/messages.")
|
|
150
|
+
.description("Send an SMS, optionally with media (MMS). POST /api/v1/messages.")
|
|
151
151
|
.option("--to <e164>", "destination phone number, E.164 (e.g. +14155551234)")
|
|
152
152
|
.option("--body <text>", "message body")
|
|
153
153
|
.option("--from-number-id <id>", "phoneNumberId to send from (defaults to onboard's number)")
|
|
154
|
+
.option("--media <path-or-url>", "media attachment: local file path (uploaded) or public http(s) URL (repeatable, max 10)", (v, prev = []) => [...prev, v], [])
|
|
155
|
+
.option("--force-audio-file", "send an audio attachment as a regular file attachment instead of an iMessage voice message")
|
|
154
156
|
.option("--json", "machine-readable output")
|
|
155
157
|
.action(async (opts) => {
|
|
156
|
-
if (!opts.to
|
|
157
|
-
console.error("error: --to
|
|
158
|
+
if (!opts.to) {
|
|
159
|
+
console.error("error: --to is required to send a message. Use `dial message list` to list, or `dial message --help` for usage.");
|
|
160
|
+
process.exit(2);
|
|
161
|
+
}
|
|
162
|
+
if (!opts.body && (opts.media ?? []).length === 0) {
|
|
163
|
+
console.error("error: provide --body, --media, or both — a message needs text or an attachment.");
|
|
158
164
|
process.exit(2);
|
|
159
165
|
}
|
|
160
166
|
process.exit(await runMessageSend({
|
|
161
167
|
to: opts.to,
|
|
162
168
|
body: opts.body,
|
|
163
169
|
fromNumberId: opts.fromNumberId,
|
|
170
|
+
media: opts.media,
|
|
171
|
+
forceAudioFile: !!opts.forceAudioFile,
|
|
164
172
|
json: !!opts.json,
|
|
165
173
|
}));
|
|
166
174
|
});
|
|
@@ -13,7 +13,8 @@ export async function runMessageList(opts) {
|
|
|
13
13
|
return 0;
|
|
14
14
|
}
|
|
15
15
|
for (const m of messages) {
|
|
16
|
-
|
|
16
|
+
const mediaTag = m.media && m.media.length > 0 ? ` [${m.media.length} media]` : "";
|
|
17
|
+
console.log(`${m.createdAt} ${(m.direction ?? "").padEnd(8)} ${m.from} -> ${m.to} ${m.body}${mediaTag}`);
|
|
17
18
|
}
|
|
18
19
|
return 0;
|
|
19
20
|
}
|
|
@@ -3,7 +3,13 @@ import { isDialError } from "../../lib/ops/errors.js";
|
|
|
3
3
|
import { printDialError } from "../../lib/cli-error.js";
|
|
4
4
|
export async function runMessageSend(opts) {
|
|
5
5
|
try {
|
|
6
|
-
const m = await sendMessage({
|
|
6
|
+
const m = await sendMessage({
|
|
7
|
+
to: opts.to,
|
|
8
|
+
body: opts.body,
|
|
9
|
+
fromNumberId: opts.fromNumberId,
|
|
10
|
+
media: opts.media,
|
|
11
|
+
forceAudioFile: opts.forceAudioFile,
|
|
12
|
+
});
|
|
7
13
|
if (opts.json) {
|
|
8
14
|
console.log(JSON.stringify({ ok: true, message: m }));
|
|
9
15
|
}
|
|
@@ -13,6 +19,9 @@ export async function runMessageSend(opts) {
|
|
|
13
19
|
console.log(` from: ${m.from}`);
|
|
14
20
|
console.log(` to: ${m.to}`);
|
|
15
21
|
console.log(` body: ${m.body}`);
|
|
22
|
+
for (const item of m.media ?? []) {
|
|
23
|
+
console.log(` media: ${item.url} (${item.contentType})`);
|
|
24
|
+
}
|
|
16
25
|
console.log(` status: ${m.status}`);
|
|
17
26
|
}
|
|
18
27
|
return 0;
|
package/dist/lib/api.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { request } from "undici";
|
|
1
|
+
import { request, fetch as undiciFetch, FormData as UndiciFormData } from "undici";
|
|
2
2
|
import { logger } from "./log.js";
|
|
3
3
|
import { VERSION } from "./version.js";
|
|
4
|
+
// The bundled undici only multipart-encodes its own FormData class (realm
|
|
5
|
+
// check) — Node's global FormData would be coerced to a text/plain string.
|
|
6
|
+
export { UndiciFormData as ApiFormData };
|
|
4
7
|
const DEFAULT_BASE = "https://api.getdial.ai";
|
|
5
8
|
// Identifies the CLI on every request, so server-side request logs attribute
|
|
6
9
|
// provisioning (and everything else) to the client + version that made the call.
|
|
@@ -17,6 +20,26 @@ export async function apiGet(path, apiKey) {
|
|
|
17
20
|
export async function apiPatch(path, body, apiKey) {
|
|
18
21
|
return apiRequest("PATCH", path, body, apiKey);
|
|
19
22
|
}
|
|
23
|
+
function toResult(statusCode, text) {
|
|
24
|
+
let parsed = null;
|
|
25
|
+
try {
|
|
26
|
+
parsed = text ? JSON.parse(text) : null;
|
|
27
|
+
}
|
|
28
|
+
catch { /* keep raw */ }
|
|
29
|
+
if (statusCode >= 200 && statusCode < 300) {
|
|
30
|
+
return { ok: true, status: statusCode, data: parsed };
|
|
31
|
+
}
|
|
32
|
+
// The server's `error` field is usually a string, but validation failures
|
|
33
|
+
// return a structured object (Zod's flatten). Stringify those as JSON rather
|
|
34
|
+
// than letting them coerce to "[object Object]", so the real reason survives.
|
|
35
|
+
const rawError = parsed?.error;
|
|
36
|
+
const errMsg = typeof rawError === "string"
|
|
37
|
+
? rawError
|
|
38
|
+
: rawError != null
|
|
39
|
+
? JSON.stringify(rawError)
|
|
40
|
+
: text || `HTTP ${statusCode}`;
|
|
41
|
+
return { ok: false, status: statusCode, error: errMsg };
|
|
42
|
+
}
|
|
20
43
|
async function apiRequest(method, path, body, apiKey, extraHeaders) {
|
|
21
44
|
const url = `${baseUrl()}${path}`;
|
|
22
45
|
const headers = { "content-type": "application/json", "user-agent": USER_AGENT, ...(extraHeaders ?? {}) };
|
|
@@ -28,25 +51,21 @@ async function apiRequest(method, path, body, apiKey, extraHeaders) {
|
|
|
28
51
|
headers,
|
|
29
52
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
30
53
|
});
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
: rawError != null
|
|
47
|
-
? JSON.stringify(rawError)
|
|
48
|
-
: text || `HTTP ${res.statusCode}`;
|
|
49
|
-
return { ok: false, status: res.statusCode, error: errMsg };
|
|
54
|
+
return toResult(res.statusCode, await res.body.text());
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** POST a multipart/form-data body (file uploads). fetch sets the boundary header itself. */
|
|
61
|
+
export async function apiPostMultipart(path, form, apiKey) {
|
|
62
|
+
const url = `${baseUrl()}${path}`;
|
|
63
|
+
const headers = {};
|
|
64
|
+
if (apiKey)
|
|
65
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
66
|
+
try {
|
|
67
|
+
const res = await undiciFetch(url, { method: "POST", headers, body: form });
|
|
68
|
+
return toResult(res.status, await res.text());
|
|
50
69
|
}
|
|
51
70
|
catch (err) {
|
|
52
71
|
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
package/dist/lib/ops/messages.js
CHANGED
|
@@ -1,13 +1,90 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, extname } from "node:path";
|
|
3
|
+
import { apiGet, apiPost, apiPostMultipart, ApiFormData } from "../api.js";
|
|
2
4
|
import { requireAuth, requireFromNumberId } from "./auth.js";
|
|
3
5
|
import { DialError } from "./errors.js";
|
|
6
|
+
export const MAX_MEDIA_ITEMS = 10;
|
|
7
|
+
// File extensions the API accepts for uploads, mapped to their MIME type
|
|
8
|
+
// (mirrors the server's supported-content-type list).
|
|
9
|
+
const EXT_CONTENT_TYPE = {
|
|
10
|
+
jpg: "image/jpeg",
|
|
11
|
+
jpeg: "image/jpeg",
|
|
12
|
+
png: "image/png",
|
|
13
|
+
gif: "image/gif",
|
|
14
|
+
webp: "image/webp",
|
|
15
|
+
bmp: "image/bmp",
|
|
16
|
+
mp3: "audio/mpeg",
|
|
17
|
+
m4a: "audio/mp4",
|
|
18
|
+
ogg: "audio/ogg",
|
|
19
|
+
wav: "audio/wav",
|
|
20
|
+
amr: "audio/amr",
|
|
21
|
+
mp4: "video/mp4",
|
|
22
|
+
"3gp": "video/3gpp",
|
|
23
|
+
pdf: "application/pdf",
|
|
24
|
+
vcf: "text/vcard",
|
|
25
|
+
ics: "text/calendar",
|
|
26
|
+
};
|
|
27
|
+
function isHttpUrl(value) {
|
|
28
|
+
return /^https?:\/\//i.test(value);
|
|
29
|
+
}
|
|
30
|
+
/** Read a local media file and resolve its MIME type from the extension. */
|
|
31
|
+
function readMediaFile(path) {
|
|
32
|
+
const ext = extname(path).slice(1).toLowerCase();
|
|
33
|
+
const contentType = EXT_CONTENT_TYPE[ext];
|
|
34
|
+
if (!contentType) {
|
|
35
|
+
const supported = Object.keys(EXT_CONTENT_TYPE).join(", ");
|
|
36
|
+
throw new DialError("unsupported_media", `unsupported media file extension ".${ext}" (${path}). Supported: ${supported}`);
|
|
37
|
+
}
|
|
38
|
+
let data;
|
|
39
|
+
try {
|
|
40
|
+
data = readFileSync(path);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
throw new DialError("media_read_failed", `could not read media file ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
44
|
+
}
|
|
45
|
+
return { data, contentType, name: basename(path) };
|
|
46
|
+
}
|
|
4
47
|
export async function sendMessage(opts) {
|
|
5
48
|
const auth = requireAuth();
|
|
6
49
|
const fromNumberId = requireFromNumberId(auth, opts.fromNumberId);
|
|
50
|
+
const media = opts.media ?? [];
|
|
51
|
+
if (media.length > MAX_MEDIA_ITEMS) {
|
|
52
|
+
throw new DialError("too_much_media", `at most ${MAX_MEDIA_ITEMS} media items are allowed per message (got ${media.length})`);
|
|
53
|
+
}
|
|
7
54
|
// No `channel`: the server determines it from the from-number (a standard number
|
|
8
55
|
// sends SMS; an iMessage number sends iMessage with RCS/SMS fallback) and its send
|
|
9
56
|
// schema is strict — sending a stale `channel` field is rejected as a 400.
|
|
10
|
-
|
|
57
|
+
// URLs-only goes as plain JSON; any local file switches to multipart.
|
|
58
|
+
const hasFiles = media.some((m) => !isHttpUrl(m));
|
|
59
|
+
let res;
|
|
60
|
+
if (!hasFiles) {
|
|
61
|
+
res = await apiPost("/api/v1/messages", {
|
|
62
|
+
to: opts.to,
|
|
63
|
+
...(opts.body ? { body: opts.body } : {}),
|
|
64
|
+
fromNumberId,
|
|
65
|
+
...(media.length ? { mediaUrls: media } : {}),
|
|
66
|
+
...(opts.forceAudioFile ? { forceAudioFile: true } : {}),
|
|
67
|
+
}, auth.apiKey);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const form = new ApiFormData();
|
|
71
|
+
form.set("to", opts.to);
|
|
72
|
+
if (opts.body)
|
|
73
|
+
form.set("body", opts.body);
|
|
74
|
+
form.set("fromNumberId", fromNumberId);
|
|
75
|
+
if (opts.forceAudioFile)
|
|
76
|
+
form.set("forceAudioFile", "true");
|
|
77
|
+
for (const item of media) {
|
|
78
|
+
if (isHttpUrl(item)) {
|
|
79
|
+
form.append("mediaUrls", item);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const file = readMediaFile(item);
|
|
83
|
+
form.append("media", new Blob([new Uint8Array(file.data)], { type: file.contentType }), file.name);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
res = await apiPostMultipart("/api/v1/messages", form, auth.apiKey);
|
|
87
|
+
}
|
|
11
88
|
if (!res.ok)
|
|
12
89
|
throw new DialError("send_failed", res.error, res.status);
|
|
13
90
|
return res.data.message;
|
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { jsonResult } from "../result.js";
|
|
3
|
-
import { sendMessage } from "../../lib/ops/messages.js";
|
|
3
|
+
import { sendMessage, MAX_MEDIA_ITEMS } from "../../lib/ops/messages.js";
|
|
4
4
|
import { messageSchema } from "../schemas.js";
|
|
5
5
|
const inputSchema = {
|
|
6
6
|
to: z.string().min(7).describe("Destination phone number, E.164 (e.g. +14155550123)"),
|
|
7
|
-
body: z.string().
|
|
7
|
+
body: z.string().optional().describe("Message body; optional when mediaUrls is given (media-only send)"),
|
|
8
8
|
fromNumberId: z.string().optional().describe("Number id to send from; defaults to your primary number"),
|
|
9
|
+
mediaUrls: z
|
|
10
|
+
.array(z.string().url())
|
|
11
|
+
.max(MAX_MEDIA_ITEMS)
|
|
12
|
+
.optional()
|
|
13
|
+
.describe("Publicly reachable http(s) URLs of media to attach (MMS); Dial mirrors and re-hosts them"),
|
|
14
|
+
forceAudioFile: z
|
|
15
|
+
.boolean()
|
|
16
|
+
.optional()
|
|
17
|
+
.describe("Send an audio attachment as a regular file attachment instead of an iMessage voice message. No effect on standard numbers or non-audio media."),
|
|
9
18
|
};
|
|
10
19
|
export const sendMessageTool = {
|
|
11
20
|
name: "send_message",
|
|
12
21
|
config: {
|
|
13
|
-
title: "Send
|
|
14
|
-
description: "Send
|
|
22
|
+
title: "Send message",
|
|
23
|
+
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.",
|
|
15
24
|
inputSchema,
|
|
16
25
|
outputSchema: { message: messageSchema },
|
|
17
26
|
annotations: { openWorldHint: true },
|
|
@@ -21,6 +30,8 @@ export const sendMessageTool = {
|
|
|
21
30
|
to: args.to,
|
|
22
31
|
body: args.body,
|
|
23
32
|
fromNumberId: args.fromNumberId,
|
|
33
|
+
media: args.mediaUrls,
|
|
34
|
+
forceAudioFile: args.forceAudioFile,
|
|
24
35
|
}),
|
|
25
36
|
}),
|
|
26
37
|
};
|
package/package.json
CHANGED
package/skills.tar.gz
CHANGED
|
Binary file
|