@getdial/cli 0.26.0 → 0.27.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
@@ -147,10 +147,11 @@ 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], [])
154
155
  .option("--json", "machine-readable output")
155
156
  .action(async (opts) => {
156
157
  if (!opts.to || !opts.body) {
@@ -161,6 +162,7 @@ const message = program
161
162
  to: opts.to,
162
163
  body: opts.body,
163
164
  fromNumberId: opts.fromNumberId,
165
+ media: opts.media,
164
166
  json: !!opts.json,
165
167
  }));
166
168
  });
@@ -13,7 +13,8 @@ export async function runMessageList(opts) {
13
13
  return 0;
14
14
  }
15
15
  for (const m of messages) {
16
- console.log(`${m.createdAt} ${(m.direction ?? "").padEnd(8)} ${m.from} -> ${m.to} ${m.body}`);
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,7 @@ 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({ to: opts.to, body: opts.body, fromNumberId: opts.fromNumberId });
6
+ const m = await sendMessage({ to: opts.to, body: opts.body, fromNumberId: opts.fromNumberId, media: opts.media });
7
7
  if (opts.json) {
8
8
  console.log(JSON.stringify({ ok: true, message: m }));
9
9
  }
@@ -13,6 +13,9 @@ export async function runMessageSend(opts) {
13
13
  console.log(` from: ${m.from}`);
14
14
  console.log(` to: ${m.to}`);
15
15
  console.log(` body: ${m.body}`);
16
+ for (const item of m.media ?? []) {
17
+ console.log(` media: ${item.url} (${item.contentType})`);
18
+ }
16
19
  console.log(` status: ${m.status}`);
17
20
  }
18
21
  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
- const text = await res.body.text();
32
- let parsed = null;
33
- try {
34
- parsed = text ? JSON.parse(text) : null;
35
- }
36
- catch { /* keep raw */ }
37
- if (res.statusCode >= 200 && res.statusCode < 300) {
38
- return { ok: true, status: res.statusCode, data: parsed };
39
- }
40
- // The server's `error` field is usually a string, but validation failures
41
- // return a structured object (Zod's flatten). Stringify those as JSON rather
42
- // than letting them coerce to "[object Object]", so the real reason survives.
43
- const rawError = parsed?.error;
44
- const errMsg = typeof rawError === "string"
45
- ? rawError
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) };
@@ -1,13 +1,81 @@
1
- import { apiGet, apiPost } from "../api.js";
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
- const res = await apiPost("/api/v1/messages", { to: opts.to, body: opts.body, fromNumberId }, auth.apiKey);
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", { to: opts.to, body: opts.body, fromNumberId, ...(media.length ? { mediaUrls: media } : {}) }, auth.apiKey);
62
+ }
63
+ else {
64
+ const form = new ApiFormData();
65
+ form.set("to", opts.to);
66
+ form.set("body", opts.body);
67
+ form.set("fromNumberId", fromNumberId);
68
+ for (const item of media) {
69
+ if (isHttpUrl(item)) {
70
+ form.append("mediaUrls", item);
71
+ }
72
+ else {
73
+ const file = readMediaFile(item);
74
+ form.append("media", new Blob([new Uint8Array(file.data)], { type: file.contentType }), file.name);
75
+ }
76
+ }
77
+ res = await apiPostMultipart("/api/v1/messages", form, auth.apiKey);
78
+ }
11
79
  if (!res.ok)
12
80
  throw new DialError("send_failed", res.error, res.status);
13
81
  return res.data.message;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getdial/cli",
3
- "version": "0.26.0",
3
+ "version": "0.27.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