@getdial/cli 0.41.0 → 0.42.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
@@ -18,6 +18,7 @@ import { runNumberPurchase } from "./commands/number/purchase.js";
18
18
  import { runNumberSet } from "./commands/number/set.js";
19
19
  import { runNumberWhatsapp } from "./commands/number/whatsapp.js";
20
20
  import { runGroupList } from "./commands/group/list.js";
21
+ import { runContactsList } from "./commands/contacts/list.js";
21
22
  import { runMessageSend } from "./commands/message/send.js";
22
23
  import { runMessageReply } from "./commands/message/reply.js";
23
24
  import { runMessageList } from "./commands/message/list.js";
@@ -309,15 +310,31 @@ message
309
310
  .command("list")
310
311
  .description("List recent messages on your account. GET /api/v1/messages.")
311
312
  .option("--number-id <id>", "filter to a single phone number")
313
+ .option("--contact <e164>", "one contact's conversation, both directions, across every line")
312
314
  .option("--group <id>", "filter to one group conversation (see `dial group list`)")
313
315
  .option("--direction <dir>", "inbound or outbound")
314
316
  .option("--since <iso8601>", "only messages created after this timestamp")
317
+ .option("--search <text>", "match message bodies containing this text (case-insensitive)")
315
318
  .option("--json", "machine-readable output")
316
319
  .action(async (opts) => process.exit(await runMessageList({
317
320
  numberId: opts.numberId,
321
+ contact: opts.contact,
318
322
  group: opts.group,
319
323
  direction: opts.direction,
320
324
  since: opts.since,
325
+ search: opts.search,
326
+ json: !!opts.json,
327
+ })));
328
+ program
329
+ .command("contacts")
330
+ .description("Every number your lines have texted or called, newest activity first. Walks every page " +
331
+ "unless you pass --limit. GET /api/v1/contacts.")
332
+ .option("--limit <n>", "return one page of at most n contacts (1-1000) instead of all", (v) => Number.parseInt(v, 10))
333
+ .option("--starting-after <iso8601>", "page cursor: the lastAt of the last contact you received")
334
+ .option("--json", "machine-readable output")
335
+ .action(async (opts) => process.exit(await runContactsList({
336
+ limit: opts.limit,
337
+ startingAfter: opts.startingAfter,
321
338
  json: !!opts.json,
322
339
  })));
323
340
  const group = program
@@ -411,11 +428,13 @@ call
411
428
  .command("list")
412
429
  .description("List recent calls on your account. GET /api/v1/calls.")
413
430
  .option("--number-id <id>", "filter to a single phone number")
431
+ .option("--contact <e164>", "one contact's calls, both directions, across every line")
414
432
  .option("--direction <dir>", "inbound or outbound")
415
433
  .option("--since <iso8601>", "only calls created after this timestamp")
416
434
  .option("--json", "machine-readable output")
417
435
  .action(async (opts) => process.exit(await runCallList({
418
436
  numberId: opts.numberId,
437
+ contact: opts.contact,
419
438
  direction: opts.direction,
420
439
  since: opts.since,
421
440
  json: !!opts.json,
@@ -5,6 +5,7 @@ export async function runCallList(opts) {
5
5
  try {
6
6
  const calls = await listCalls({
7
7
  numberId: opts.numberId,
8
+ contact: opts.contact,
8
9
  direction: opts.direction,
9
10
  since: opts.since,
10
11
  });
@@ -0,0 +1,59 @@
1
+ import { listContacts, listAllContacts } from "../../lib/ops/contacts.js";
2
+ import { isDialError } from "../../lib/ops/errors.js";
3
+ import { printDialError } from "../../lib/cli-error.js";
4
+ /**
5
+ * The preview line for one contact.
6
+ *
7
+ * The API reports facts, not a sentence, so the wording is chosen here — which also means the
8
+ * terminal and the dashboard can each say it their own way without the server picking for them.
9
+ */
10
+ function preview(c) {
11
+ if (c.lastKind === "call") {
12
+ const direction = c.lastDirection === "inbound" ? "incoming" : "outgoing";
13
+ // 0 seconds means it never connected, so a duration is only worth printing above it.
14
+ return c.lastCallDuration ? `${direction} call, ${c.lastCallDuration}s` : `${direction} call`;
15
+ }
16
+ if (c.lastRedacted)
17
+ return "(deleted by retention)";
18
+ if (!c.lastBody)
19
+ return c.lastMediaCount > 0 ? `(${c.lastMediaCount} media)` : "";
20
+ const prefix = c.lastDirection === "inbound" ? "" : "you: ";
21
+ return `${prefix}${c.lastBody}`;
22
+ }
23
+ export async function runContactsList(opts) {
24
+ try {
25
+ // `--limit` asks for one page and gets exactly that, cursor included. Without it the command
26
+ // answers the question it is named for — who have I talked to — by walking every page.
27
+ const paged = opts.limit !== undefined || opts.startingAfter !== undefined;
28
+ const page = paged
29
+ ? await listContacts({ limit: opts.limit, startingAfter: opts.startingAfter })
30
+ : { contacts: await listAllContacts(), hasMore: false };
31
+ if (opts.json) {
32
+ console.log(JSON.stringify({ ok: true, contacts: page.contacts, hasMore: page.hasMore }));
33
+ return 0;
34
+ }
35
+ if (page.contacts.length === 0) {
36
+ console.log("no contacts. a number appears here once one of your lines texts or calls it.");
37
+ return 0;
38
+ }
39
+ for (const c of page.contacts) {
40
+ const counts = [
41
+ c.messageCount > 0 ? `${c.messageCount} msg` : null,
42
+ c.callCount > 0 ? `${c.callCount} call` : null,
43
+ ]
44
+ .filter(Boolean)
45
+ .join(", ");
46
+ console.log(`${c.number.padEnd(16)} ${c.lastAt} ${counts.padEnd(18)} ${preview(c)}`);
47
+ }
48
+ if (page.hasMore) {
49
+ const cursor = page.contacts[page.contacts.length - 1].lastAt;
50
+ console.log(`\nmore contacts. next page: dial contacts --starting-after ${cursor}`);
51
+ }
52
+ return 0;
53
+ }
54
+ catch (e) {
55
+ if (isDialError(e))
56
+ return printDialError(opts.json, e);
57
+ throw e;
58
+ }
59
+ }
@@ -6,8 +6,10 @@ export async function runMessageList(opts) {
6
6
  const messages = await listMessages({
7
7
  numberId: opts.numberId,
8
8
  groupId: opts.group,
9
+ contact: opts.contact,
9
10
  direction: opts.direction,
10
11
  since: opts.since,
12
+ search: opts.search,
11
13
  });
12
14
  if (opts.json) {
13
15
  console.log(JSON.stringify({ ok: true, messages }));
@@ -29,6 +29,8 @@ export async function listCalls(opts) {
29
29
  params.set("direction", opts.direction);
30
30
  if (opts.since)
31
31
  params.set("since", opts.since);
32
+ if (opts.contact)
33
+ params.set("contact", opts.contact);
32
34
  const qs = params.toString();
33
35
  const res = await apiGet(qs ? `/api/v1/calls?${qs}` : "/api/v1/calls", auth?.apiKey);
34
36
  if (!res.ok)
@@ -0,0 +1,42 @@
1
+ import { apiGet } from "../api.js";
2
+ import { maybeAuth } from "./auth.js";
3
+ import { DialError } from "./errors.js";
4
+ /**
5
+ * One page of contacts — every number the account's lines have messaged or called.
6
+ *
7
+ * Shared by `dial contacts` and the local MCP `list_contacts` tool, so both speak to the API
8
+ * through one place and inherit the saved key the same way.
9
+ */
10
+ export async function listContacts(opts = {}) {
11
+ const auth = maybeAuth();
12
+ const params = new URLSearchParams();
13
+ if (opts.limit !== undefined)
14
+ params.set("limit", String(opts.limit));
15
+ if (opts.startingAfter)
16
+ params.set("starting_after", opts.startingAfter);
17
+ const qs = params.toString();
18
+ const res = await apiGet(qs ? `/api/v1/contacts?${qs}` : "/api/v1/contacts", auth?.apiKey);
19
+ if (!res.ok)
20
+ throw new DialError("list_failed", res.error, res.status);
21
+ return { contacts: res.data.contacts ?? [], hasMore: res.data.hasMore ?? false };
22
+ }
23
+ /**
24
+ * Every contact, following the cursor until the API says there are no more.
25
+ *
26
+ * `dial contacts` answers "who have I talked to", and a first page is not an answer to that — so
27
+ * the default walks the pages. `--limit` opts back into a single page for a caller that wants one.
28
+ */
29
+ export async function listAllContacts(pageSize = 1000) {
30
+ const all = [];
31
+ let startingAfter;
32
+ // Bounded rather than `while (hasMore)`: a bug at either end that always answered `hasMore:
33
+ // true` would otherwise loop forever against the network. 25 pages is 25,000 contacts.
34
+ for (let page = 0; page < 25; page++) {
35
+ const { contacts, hasMore } = await listContacts({ limit: pageSize, startingAfter });
36
+ all.push(...contacts);
37
+ if (!hasMore || contacts.length === 0)
38
+ break;
39
+ startingAfter = contacts[contacts.length - 1].lastAt;
40
+ }
41
+ return all;
42
+ }
@@ -117,6 +117,10 @@ export async function listMessages(opts) {
117
117
  params.set("direction", opts.direction);
118
118
  if (opts.since)
119
119
  params.set("since", opts.since);
120
+ if (opts.search)
121
+ params.set("search", opts.search);
122
+ if (opts.contact)
123
+ params.set("contact", opts.contact);
120
124
  const qs = params.toString();
121
125
  const res = await apiGet(qs ? `/api/v1/messages?${qs}` : "/api/v1/messages", auth?.apiKey);
122
126
  if (!res.ok)
@@ -85,6 +85,40 @@ export const groupSchema = z.object({
85
85
  .optional()
86
86
  .describe("ISO-8601: when Dial first learned of this group (a join, or its first message)"),
87
87
  });
88
+ /**
89
+ * One derived contact. Mirrors the hosted server's `contactSchema`.
90
+ *
91
+ * The preview fields report facts rather than a sentence — `lastKind` selects whether `lastBody`
92
+ * or `lastCallDuration` means anything, and `lastRedacted` / `lastMediaCount` explain an empty
93
+ * `lastBody` — so an agent can word the summary itself.
94
+ */
95
+ export const contactSchema = z.object({
96
+ number: z.string().describe("The contact's number, E.164 — pass as `contact` to list_messages"),
97
+ messageCount: z
98
+ .number()
99
+ .describe("One-to-one messages with this contact across every line on the account, both directions"),
100
+ callCount: z
101
+ .number()
102
+ .describe("Calls with this contact across every line on the account, both directions"),
103
+ lastAt: z.string().describe("ISO-8601 timestamp of the most recent message or call"),
104
+ lastDirection: z
105
+ .enum(["inbound", "outbound"])
106
+ .describe("Whether the most recent interaction came from them or you"),
107
+ lastKind: z.enum(["message", "call"]).describe("What the most recent interaction was"),
108
+ lastBody: z
109
+ .string()
110
+ .describe("The most recent message's text. Empty for a call, a media-only message, or a redacted one"),
111
+ lastMediaCount: z
112
+ .number()
113
+ .describe("Attachments on the most recent message; 0 for a call or a text-only message"),
114
+ lastRedacted: z
115
+ .boolean()
116
+ .describe("True when data retention cleared the most recent message's content"),
117
+ lastCallDuration: z
118
+ .number()
119
+ .nullable()
120
+ .describe("The most recent call's duration in seconds, or null when the most recent interaction was a message"),
121
+ });
88
122
  export const messageSchema = z
89
123
  .object({
90
124
  id: z.string(),
@@ -7,6 +7,7 @@ import { startTypingTool } from "./start-typing.js";
7
7
  import { stopTypingTool } from "./stop-typing.js";
8
8
  import { listMessagesTool } from "./list-messages.js";
9
9
  import { listGroupsTool } from "./list-groups.js";
10
+ import { listContactsTool } from "./list-contacts.js";
10
11
  import { placeCallTool } from "./place-call.js";
11
12
  import { listCallsTool } from "./list-calls.js";
12
13
  import { getCallTool } from "./get-call.js";
@@ -33,6 +34,7 @@ export const tools = [
33
34
  stopTypingTool,
34
35
  listMessagesTool,
35
36
  listGroupsTool,
37
+ listContactsTool,
36
38
  placeCallTool,
37
39
  listCallsTool,
38
40
  getCallTool,
@@ -3,6 +3,10 @@ import { jsonResult } from "../result.js";
3
3
  import { listCalls } from "../../lib/ops/calls.js";
4
4
  import { callSchema } from "../schemas.js";
5
5
  const inputSchema = {
6
+ contact: z
7
+ .string()
8
+ .optional()
9
+ .describe("One conversation: calls exchanged with this number (E.164), both directions, across every line"),
6
10
  numberId: z.string().optional().describe("Filter to a single phone number id"),
7
11
  direction: z.enum(["inbound", "outbound"]).optional().describe("Filter by direction"),
8
12
  since: z.string().optional().describe("Only calls created after this ISO-8601 timestamp"),
@@ -11,7 +15,7 @@ export const listCallsTool = {
11
15
  name: "list_calls",
12
16
  config: {
13
17
  title: "List Calls",
14
- description: "List recent calls on your account, newest first.",
18
+ description: "List recent calls on your account, newest first. Pass `contact` to read one person's call history.",
15
19
  inputSchema,
16
20
  outputSchema: { calls: z.array(callSchema) },
17
21
  annotations: { readOnlyHint: true, openWorldHint: true },
@@ -21,6 +25,7 @@ export const listCallsTool = {
21
25
  numberId: args.numberId,
22
26
  direction: args.direction,
23
27
  since: args.since,
28
+ contact: args.contact,
24
29
  }),
25
30
  }),
26
31
  };
@@ -0,0 +1,35 @@
1
+ import { z } from "zod";
2
+ import { jsonResult } from "../result.js";
3
+ import { listContacts } from "../../lib/ops/contacts.js";
4
+ import { contactSchema } from "../schemas.js";
5
+ const inputSchema = {
6
+ limit: z
7
+ .number()
8
+ .int()
9
+ .min(1)
10
+ .max(1000)
11
+ .optional()
12
+ .describe("Max contacts to return (1-1000; default 100)"),
13
+ startingAfter: z
14
+ .string()
15
+ .optional()
16
+ .describe("ISO-8601 cursor — the lastAt of the last contact you received; returns only older ones"),
17
+ };
18
+ export const listContactsTool = {
19
+ name: "list_contacts",
20
+ config: {
21
+ title: "List Contacts",
22
+ description: "List every number your lines have exchanged a message or a call with, newest activity first, with " +
23
+ "per-contact counts and a summary of the most recent interaction. Derived from history — there is no " +
24
+ "address book to add anyone to. Counts span every line on the account, so one person reached on two of " +
25
+ "your numbers is one contact. Group conversations are not contacts; use list_groups for those. Pass a " +
26
+ "contact's `number` to list_messages as `contact` to read that one conversation.",
27
+ inputSchema,
28
+ outputSchema: { contacts: z.array(contactSchema), hasMore: z.boolean() },
29
+ annotations: { readOnlyHint: true, openWorldHint: true },
30
+ },
31
+ run: async (args) => jsonResult(await listContacts({
32
+ limit: args.limit,
33
+ startingAfter: args.startingAfter,
34
+ })),
35
+ };
@@ -3,6 +3,15 @@ import { jsonResult } from "../result.js";
3
3
  import { listMessages } from "../../lib/ops/messages.js";
4
4
  import { messageSchema } from "../schemas.js";
5
5
  const inputSchema = {
6
+ search: z
7
+ .string()
8
+ .optional()
9
+ .describe("Case-insensitive substring match on the message body; searches your whole history"),
10
+ contact: z
11
+ .string()
12
+ .optional()
13
+ .describe("One conversation: messages exchanged with this number (E.164), both directions, across " +
14
+ "every line. Group messages are never included — use groupId for those"),
6
15
  numberId: z.string().optional().describe("Filter to a single phone number id"),
7
16
  groupId: z
8
17
  .string()
@@ -15,9 +24,11 @@ export const listMessagesTool = {
15
24
  name: "list_messages",
16
25
  config: {
17
26
  title: "List Messages",
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.",
27
+ description: "List recent messages on your account, newest first. Pass `contact` to read one person's " +
28
+ "whole conversation, or `groupId` to read one group's. Pass `search` to find messages by " +
29
+ "their text — that searches all of your history, then returns the 100 most recent matches. " +
30
+ "On a group message `to` is null and the destination is groupId; which of your numbers the " +
31
+ "conversation is on is phoneNumberId.",
21
32
  inputSchema,
22
33
  outputSchema: { messages: z.array(messageSchema) },
23
34
  annotations: { readOnlyHint: true, openWorldHint: true },
@@ -28,6 +39,8 @@ export const listMessagesTool = {
28
39
  groupId: args.groupId,
29
40
  direction: args.direction,
30
41
  since: args.since,
42
+ search: args.search,
43
+ contact: args.contact,
31
44
  }),
32
45
  }),
33
46
  };
@@ -30,6 +30,7 @@ export const OPERATIONAL_TOOL_NAMES = [
30
30
  "stop_typing",
31
31
  "list_messages",
32
32
  "list_groups",
33
+ "list_contacts",
33
34
  "place_call",
34
35
  "list_calls",
35
36
  "get_call",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getdial/cli",
3
- "version": "0.41.0",
3
+ "version": "0.42.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