niahere 0.2.90 → 0.3.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.
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Bridges a Twilio Media Streams WebSocket to OpenAI's Realtime API.
3
+ *
4
+ * Both endpoints use g711_ulaw audio at 8kHz so no resampling is needed —
5
+ * we pass the base64 audio payload through after reframing JSON envelopes.
6
+ *
7
+ * Usage:
8
+ * const relay = createRelay({ ... });
9
+ * await relay.ready; // session.update has been sent
10
+ * relay.onTwilioMessage(raw); // for each frame from the Twilio WS
11
+ * relay.onTwilioClose(); // when the Twilio WS closes
12
+ * const result = await relay.completion;
13
+ */
14
+ import { log } from "../../utils/log";
15
+
16
+ export interface PhoneToolDefinition {
17
+ name: string;
18
+ description: string;
19
+ parameters: Record<string, unknown>;
20
+ handler: (args: Record<string, unknown>) => Promise<string>;
21
+ }
22
+
23
+ export interface CallContext {
24
+ callSid: string;
25
+ streamSid?: string;
26
+ direction: "inbound" | "outbound";
27
+ /** Caller phone number (E.164) for inbound, callee for outbound. */
28
+ remoteNumber: string | null;
29
+ /** Owner/contact label for logging and persona context. */
30
+ remoteLabel: string;
31
+ instructions: string;
32
+ tools: PhoneToolDefinition[];
33
+ /** Whether the model should speak first (true for outbound calls). */
34
+ speakFirst: boolean;
35
+ /** Optional opener line for outbound; the model expands on it. */
36
+ opener?: string;
37
+ }
38
+
39
+ export interface TranscriptTurn {
40
+ role: "user" | "assistant";
41
+ text: string;
42
+ ts: number;
43
+ }
44
+
45
+ export type RelayEndReason = "twilio_stop" | "openai_close" | "tool_end_call" | "error";
46
+
47
+ export interface RelayResult {
48
+ transcript: TranscriptTurn[];
49
+ endedReason: RelayEndReason;
50
+ error?: string;
51
+ }
52
+
53
+ export interface RelayOpts {
54
+ /** WebSocket bound to the Twilio Media Stream connection. */
55
+ twilioWs: WebSocketLike;
56
+ openAiKey: string;
57
+ model: string;
58
+ voice: string;
59
+ context: CallContext;
60
+ }
61
+
62
+ export interface WebSocketLike {
63
+ send(data: string | ArrayBufferLike | Uint8Array): void;
64
+ close(code?: number, reason?: string): void;
65
+ readyState: number;
66
+ }
67
+
68
+ export interface RelayHandle {
69
+ /** Resolves once the OpenAI session.update has been sent. */
70
+ ready: Promise<void>;
71
+ /** Resolves once the call has fully wound down (Twilio stop or OpenAI close). */
72
+ completion: Promise<RelayResult>;
73
+ onTwilioMessage(raw: string): void;
74
+ /** Call when the Twilio WebSocket closes (event or our own close). */
75
+ onTwilioClose(): void;
76
+ }
77
+
78
+ export function createRelay(opts: RelayOpts): RelayHandle {
79
+ const { twilioWs, openAiKey, model, voice, context } = opts;
80
+ const transcript: TranscriptTurn[] = [];
81
+ let endedReason: RelayEndReason = "twilio_stop";
82
+ let errorMsg: string | undefined;
83
+ let finalized = false;
84
+ let pendingAssistantText = "";
85
+
86
+ const openAiUrl = `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`;
87
+ const openAiWs = new WebSocket(openAiUrl, {
88
+ headers: { Authorization: `Bearer ${openAiKey}` },
89
+ } as any);
90
+
91
+ /** Whether a response is currently in flight on the OpenAI side. */
92
+ let responseActive = false;
93
+ /** Whether the opener response.create has been sent yet (outbound only). */
94
+ let openerSent = false;
95
+
96
+ let resolveReady: () => void;
97
+ let rejectReady: (err: Error) => void;
98
+ const ready = new Promise<void>((res, rej) => {
99
+ resolveReady = res;
100
+ rejectReady = rej;
101
+ });
102
+
103
+ let resolveCompletion: (result: RelayResult) => void;
104
+ const completion = new Promise<RelayResult>((res) => {
105
+ resolveCompletion = res;
106
+ });
107
+
108
+ function sendOpenAi(payload: Record<string, unknown>): void {
109
+ if (openAiWs.readyState !== 1) return;
110
+ openAiWs.send(JSON.stringify(payload));
111
+ }
112
+
113
+ function sendTwilio(payload: Record<string, unknown>): void {
114
+ if (twilioWs.readyState !== 1) return;
115
+ twilioWs.send(JSON.stringify(payload));
116
+ }
117
+
118
+ function finalize(reason?: RelayEndReason): void {
119
+ if (finalized) return;
120
+ finalized = true;
121
+ if (reason) endedReason = reason;
122
+ if (pendingAssistantText.trim()) {
123
+ transcript.push({ role: "assistant", text: pendingAssistantText.trim(), ts: Date.now() });
124
+ pendingAssistantText = "";
125
+ }
126
+ try {
127
+ if (openAiWs.readyState === 1) openAiWs.close();
128
+ } catch {}
129
+ resolveCompletion({ transcript, endedReason, error: errorMsg });
130
+ }
131
+
132
+ openAiWs.addEventListener("open", () => {
133
+ log.info({ callSid: context.callSid, model }, "openai realtime: connected");
134
+ sendOpenAi({
135
+ type: "session.update",
136
+ session: {
137
+ type: "realtime",
138
+ model,
139
+ // Without this explicit list, the GA session silently drops audio
140
+ // synthesis and only emits transcripts — verified empirically.
141
+ output_modalities: ["audio"],
142
+ instructions: context.instructions,
143
+ audio: {
144
+ input: {
145
+ format: { type: "audio/pcmu" },
146
+ transcription: { model: "whisper-1" },
147
+ turn_detection: { type: "server_vad", threshold: 0.5, silence_duration_ms: 300 },
148
+ },
149
+ output: {
150
+ format: { type: "audio/pcmu" },
151
+ voice,
152
+ },
153
+ },
154
+ tools: context.tools.map((t) => ({
155
+ type: "function",
156
+ name: t.name,
157
+ description: t.description,
158
+ parameters: t.parameters,
159
+ })),
160
+ tool_choice: "auto",
161
+ },
162
+ });
163
+ // For outbound calls Nia is the caller and must speak first — fire the
164
+ // opener immediately once the session is configured. streamSid was set
165
+ // synchronously in bootstrapWsMessage before this relay was created, so
166
+ // any audio we generate now will forward to Twilio. Anti-restart logic
167
+ // lives in the outbound instructions to prevent a misheard "hi" from
168
+ // re-triggering the greeting.
169
+ if (context.speakFirst) {
170
+ openerSent = true;
171
+ sendOpenAi({
172
+ type: "response.create",
173
+ response: {
174
+ instructions: context.opener || "Greet the caller warmly and explain what you can help with.",
175
+ },
176
+ });
177
+ }
178
+ resolveReady();
179
+ });
180
+
181
+ openAiWs.addEventListener("error", (ev) => {
182
+ const msg = (ev as ErrorEvent).message || "openai websocket error";
183
+ log.error({ callSid: context.callSid, err: msg }, "openai realtime: error");
184
+ errorMsg = msg;
185
+ rejectReady(new Error(msg));
186
+ finalize("error");
187
+ });
188
+
189
+ openAiWs.addEventListener("close", () => {
190
+ log.info({ callSid: context.callSid }, "openai realtime: closed");
191
+ finalize(endedReason === "twilio_stop" ? "openai_close" : endedReason);
192
+ });
193
+
194
+ openAiWs.addEventListener("message", async (ev) => {
195
+ const isText = typeof ev.data === "string";
196
+ let evt: any;
197
+ try {
198
+ evt = JSON.parse(isText ? ev.data : new TextDecoder().decode(ev.data as ArrayBuffer));
199
+ } catch {
200
+ log.debug(
201
+ {
202
+ callSid: context.callSid,
203
+ isText,
204
+ bytes: isText ? (ev.data as string).length : (ev.data as ArrayBuffer).byteLength,
205
+ },
206
+ "openai realtime: non-json frame dropped",
207
+ );
208
+ return;
209
+ }
210
+
211
+ switch (evt.type) {
212
+ case "response.created": {
213
+ responseActive = true;
214
+ break;
215
+ }
216
+ case "response.done":
217
+ case "response.cancelled": {
218
+ responseActive = false;
219
+ break;
220
+ }
221
+ // GA event names. We keep the older `response.audio.*` aliases for
222
+ // forward/back compatibility — both fire `delta` with base64 audio.
223
+ case "response.output_audio.delta":
224
+ case "response.audio.delta": {
225
+ if (!context.streamSid) return;
226
+ sendTwilio({ event: "media", streamSid: context.streamSid, media: { payload: evt.delta } });
227
+ break;
228
+ }
229
+ case "response.output_audio_transcript.delta":
230
+ case "response.audio_transcript.delta": {
231
+ pendingAssistantText += evt.delta || "";
232
+ break;
233
+ }
234
+ case "response.output_audio_transcript.done":
235
+ case "response.audio_transcript.done": {
236
+ if (pendingAssistantText.trim()) {
237
+ transcript.push({ role: "assistant", text: pendingAssistantText.trim(), ts: Date.now() });
238
+ pendingAssistantText = "";
239
+ }
240
+ break;
241
+ }
242
+ case "conversation.item.input_audio_transcription.completed": {
243
+ const text = (evt.transcript || "").trim();
244
+ if (text) transcript.push({ role: "user", text, ts: Date.now() });
245
+ break;
246
+ }
247
+ case "input_audio_buffer.speech_started": {
248
+ if (context.streamSid) sendTwilio({ event: "clear", streamSid: context.streamSid });
249
+ if (responseActive) {
250
+ sendOpenAi({ type: "response.cancel" });
251
+ responseActive = false;
252
+ }
253
+ break;
254
+ }
255
+ case "response.function_call_arguments.done": {
256
+ const toolName = evt.name as string;
257
+ const callId = evt.call_id as string;
258
+ let args: Record<string, unknown> = {};
259
+ try {
260
+ args = JSON.parse(evt.arguments || "{}");
261
+ } catch {}
262
+ const tool = context.tools.find((t) => t.name === toolName);
263
+ let output = "";
264
+ if (!tool) {
265
+ output = `tool ${toolName} not available`;
266
+ log.warn({ callSid: context.callSid, toolName }, "phone: unknown tool requested");
267
+ } else {
268
+ try {
269
+ output = await tool.handler(args);
270
+ } catch (err) {
271
+ output = `error: ${err instanceof Error ? err.message : String(err)}`;
272
+ log.error({ err, callSid: context.callSid, toolName }, "phone tool handler failed");
273
+ }
274
+ }
275
+ sendOpenAi({
276
+ type: "conversation.item.create",
277
+ item: { type: "function_call_output", call_id: callId, output },
278
+ });
279
+ sendOpenAi({ type: "response.create" });
280
+
281
+ if (toolName === "end_call") {
282
+ setTimeout(() => finalize("tool_end_call"), 1500);
283
+ }
284
+ break;
285
+ }
286
+ case "error": {
287
+ log.error({ callSid: context.callSid, evt }, "openai realtime: error event");
288
+ break;
289
+ }
290
+ default: {
291
+ // Track unrecognized event types at debug level — invaluable when the
292
+ // GA API evolves and we need to discover new fields without breaking
293
+ // production logs. Truncate the body to keep logs sane.
294
+ const preview = JSON.stringify(evt).slice(0, 500);
295
+ log.debug({ callSid: context.callSid, type: evt.type, preview }, "openai realtime: unhandled event");
296
+ }
297
+ }
298
+ });
299
+
300
+ function onTwilioMessage(raw: string): void {
301
+ let msg: any;
302
+ try {
303
+ msg = JSON.parse(raw);
304
+ } catch {
305
+ return;
306
+ }
307
+ switch (msg.event) {
308
+ case "connected":
309
+ return;
310
+ case "start": {
311
+ context.streamSid = msg.start?.streamSid || msg.streamSid;
312
+ log.info(
313
+ { callSid: context.callSid, streamSid: context.streamSid, direction: context.direction },
314
+ "phone: media stream started",
315
+ );
316
+ return;
317
+ }
318
+ case "media": {
319
+ const payload = msg.media?.payload;
320
+ if (payload) sendOpenAi({ type: "input_audio_buffer.append", audio: payload });
321
+ return;
322
+ }
323
+ case "stop":
324
+ finalize("twilio_stop");
325
+ return;
326
+ }
327
+ }
328
+
329
+ function onTwilioClose(): void {
330
+ finalize(endedReason);
331
+ }
332
+
333
+ return { ready, completion, onTwilioMessage, onTwilioClose };
334
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Tool definitions exposed to the realtime voice model during a call.
3
+ * Each tool is a function the model can invoke mid-conversation; the result
4
+ * is fed back into the response so the model can speak it.
5
+ */
6
+ import { log } from "../../utils/log";
7
+ import { addMemory } from "../../utils/memory";
8
+ import { getChannel } from "../registry";
9
+ import { consultClaude } from "./consult";
10
+ import type { PhoneToolDefinition } from "./relay";
11
+
12
+ interface ToolContextOpts {
13
+ /** Display name of the remote party (owner, contact, or raw number). */
14
+ callerLabel: string;
15
+ }
16
+
17
+ export function buildPhoneTools(ctx: ToolContextOpts): PhoneToolDefinition[] {
18
+ return [
19
+ {
20
+ name: "consult_claude",
21
+ description:
22
+ "Ask Claude a question that needs memory, careful reasoning, or up-to-date context (e.g. 'what did I say about X last week?', 'should I interrupt Aman now?'). Returns a concise answer you can speak.",
23
+ parameters: {
24
+ type: "object",
25
+ properties: {
26
+ question: { type: "string", description: "The question to ask, in plain English." },
27
+ },
28
+ required: ["question"],
29
+ },
30
+ handler: async (args) => {
31
+ const question = String(args.question || "");
32
+ if (!question.trim()) return "(no question provided)";
33
+ return await consultClaude(question, ctx.callerLabel);
34
+ },
35
+ },
36
+ {
37
+ name: "send_telegram",
38
+ description:
39
+ "Send a short message to the owner's Telegram (e.g. a summary of the call, an action item). Use sparingly — only when something needs to land outside the call.",
40
+ parameters: {
41
+ type: "object",
42
+ properties: {
43
+ text: { type: "string", description: "Message to send. Keep it under 400 chars." },
44
+ },
45
+ required: ["text"],
46
+ },
47
+ handler: async (args) => {
48
+ const text = String(args.text || "").slice(0, 1000);
49
+ const tg = getChannel("telegram");
50
+ if (!tg || !tg.sendMessage) return "telegram unavailable";
51
+ await tg.sendMessage(`[Phone] ${text}`);
52
+ return "sent";
53
+ },
54
+ },
55
+ {
56
+ name: "save_memory",
57
+ description:
58
+ "Save a single concise insight to long-term memory (max 300 chars). Use for facts or preferences worth remembering across sessions.",
59
+ parameters: {
60
+ type: "object",
61
+ properties: {
62
+ entry: { type: "string", description: "One sentence, no transcripts." },
63
+ },
64
+ required: ["entry"],
65
+ },
66
+ handler: async (args) => addMemory(String(args.entry || "")),
67
+ },
68
+ {
69
+ name: "end_call",
70
+ description: "Politely end the call after a short goodbye. Use when the conversation is naturally complete.",
71
+ parameters: {
72
+ type: "object",
73
+ properties: {
74
+ reason: { type: "string", description: "Why the call is ending (logged, not spoken)." },
75
+ },
76
+ },
77
+ handler: async (args) => {
78
+ log.info({ reason: args.reason }, "phone: end_call invoked");
79
+ return "ending call";
80
+ },
81
+ },
82
+ ];
83
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Minimal Twilio REST + webhook-signature helpers.
3
+ * Skips the official SDK so the daemon's dependency surface stays small —
4
+ * the surface we use (place call, hang up, swap URL, validate signature) is
5
+ * a handful of well-documented endpoints.
6
+ */
7
+ import { createHmac, timingSafeEqual } from "crypto";
8
+
9
+ const TWILIO_BASE = "https://api.twilio.com/2010-04-01";
10
+
11
+ interface TwilioCreds {
12
+ accountSid: string;
13
+ authToken: string;
14
+ }
15
+
16
+ function basicAuth({ accountSid, authToken }: TwilioCreds): string {
17
+ return `Basic ${Buffer.from(`${accountSid}:${authToken}`).toString("base64")}`;
18
+ }
19
+
20
+ function accountUrl({ accountSid }: TwilioCreds, suffix: string): string {
21
+ return `${TWILIO_BASE}/Accounts/${encodeURIComponent(accountSid)}${suffix}`;
22
+ }
23
+
24
+ /**
25
+ * Validate a Twilio webhook signature.
26
+ * Algorithm (per Twilio's webhook security docs):
27
+ * 1. Take the full URL Twilio sent the request to (including query string).
28
+ * 2. For application/x-www-form-urlencoded bodies, sort POST keys and
29
+ * append each "key" + "value" to the URL string.
30
+ * 3. HMAC-SHA1 with the account AuthToken, base64-encode.
31
+ * 4. Timing-safe compare with the X-Twilio-Signature header.
32
+ */
33
+ export function validateTwilioSignature(opts: {
34
+ authToken: string;
35
+ fullUrl: string;
36
+ params: Record<string, string>;
37
+ signature: string;
38
+ }): boolean {
39
+ const { authToken, fullUrl, params, signature } = opts;
40
+ if (!signature) return false;
41
+
42
+ const sortedKeys = Object.keys(params).sort();
43
+ let data = fullUrl;
44
+ for (const key of sortedKeys) {
45
+ data += key + params[key];
46
+ }
47
+
48
+ const computed = createHmac("sha1", authToken).update(data, "utf8").digest("base64");
49
+ const a = Buffer.from(computed);
50
+ const b = Buffer.from(signature);
51
+ if (a.length !== b.length) return false;
52
+ return timingSafeEqual(a, b);
53
+ }
54
+
55
+ export interface PlaceCallOpts extends TwilioCreds {
56
+ to: string;
57
+ from: string;
58
+ twimlUrl: string;
59
+ statusCallbackUrl?: string;
60
+ /** Hard cap on call duration (seconds). Maps to Twilio's TimeLimit param. */
61
+ maxDurationSec?: number;
62
+ }
63
+
64
+ export interface PlaceCallResult {
65
+ callSid: string;
66
+ status: string;
67
+ }
68
+
69
+ export async function placeCall(opts: PlaceCallOpts): Promise<PlaceCallResult> {
70
+ const body = new URLSearchParams({
71
+ To: opts.to,
72
+ From: opts.from,
73
+ Url: opts.twimlUrl,
74
+ Method: "POST",
75
+ });
76
+ if (opts.statusCallbackUrl) {
77
+ body.set("StatusCallback", opts.statusCallbackUrl);
78
+ body.set("StatusCallbackMethod", "POST");
79
+ body.append("StatusCallbackEvent", "initiated");
80
+ body.append("StatusCallbackEvent", "answered");
81
+ body.append("StatusCallbackEvent", "completed");
82
+ }
83
+ if (opts.maxDurationSec && opts.maxDurationSec > 0) {
84
+ body.set("TimeLimit", String(opts.maxDurationSec));
85
+ }
86
+
87
+ const resp = await fetch(accountUrl(opts, "/Calls.json"), {
88
+ method: "POST",
89
+ headers: { Authorization: basicAuth(opts), "Content-Type": "application/x-www-form-urlencoded" },
90
+ body: body.toString(),
91
+ });
92
+ if (!resp.ok) {
93
+ const text = await resp.text().catch(() => "");
94
+ throw new Error(`Twilio placeCall failed: ${resp.status} ${text}`);
95
+ }
96
+ const data = (await resp.json()) as { sid: string; status: string };
97
+ return { callSid: data.sid, status: data.status };
98
+ }
99
+
100
+ /** Swap the TwiML URL on an in-flight call (used to inject the real callSid into the path). */
101
+ export async function updateCallUrl(opts: TwilioCreds & { callSid: string; url: string }): Promise<void> {
102
+ const body = new URLSearchParams({ Url: opts.url, Method: "POST" });
103
+ const resp = await fetch(accountUrl(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`), {
104
+ method: "POST",
105
+ headers: { Authorization: basicAuth(opts), "Content-Type": "application/x-www-form-urlencoded" },
106
+ body: body.toString(),
107
+ });
108
+ if (!resp.ok) {
109
+ const text = await resp.text().catch(() => "");
110
+ throw new Error(`Twilio updateCallUrl failed: ${resp.status} ${text}`);
111
+ }
112
+ }
113
+
114
+ export async function hangupCall(opts: TwilioCreds & { callSid: string }): Promise<void> {
115
+ const body = new URLSearchParams({ Status: "completed" });
116
+ const resp = await fetch(accountUrl(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`), {
117
+ method: "POST",
118
+ headers: { Authorization: basicAuth(opts), "Content-Type": "application/x-www-form-urlencoded" },
119
+ body: body.toString(),
120
+ });
121
+ if (!resp.ok) {
122
+ const text = await resp.text().catch(() => "");
123
+ throw new Error(`Twilio hangupCall failed: ${resp.status} ${text}`);
124
+ }
125
+ }
@@ -0,0 +1,60 @@
1
+ /** Helpers that emit Twilio Markup Language XML responses. */
2
+
3
+ function xmlEscape(s: string): string {
4
+ return s.replace(/[<>&"']/g, (c) => {
5
+ switch (c) {
6
+ case "<":
7
+ return "&lt;";
8
+ case ">":
9
+ return "&gt;";
10
+ case "&":
11
+ return "&amp;";
12
+ case '"':
13
+ return "&quot;";
14
+ case "'":
15
+ return "&apos;";
16
+ default:
17
+ return c;
18
+ }
19
+ });
20
+ }
21
+
22
+ /**
23
+ * TwiML that opens a bidirectional Media Stream to our WebSocket endpoint.
24
+ * Twilio forwards `customParams` as `customParameters` in the WS `start` event,
25
+ * which we use to attach the call's pre-built context.
26
+ */
27
+ export function streamTwiML(wssUrl: string, customParams: Record<string, string>): string {
28
+ const params = Object.entries(customParams)
29
+ .map(([k, v]) => ` <Parameter name="${xmlEscape(k)}" value="${xmlEscape(v)}" />`)
30
+ .join("\n");
31
+
32
+ return [
33
+ `<?xml version="1.0" encoding="UTF-8"?>`,
34
+ `<Response>`,
35
+ ` <Connect>`,
36
+ ` <Stream url="${xmlEscape(wssUrl)}">`,
37
+ params,
38
+ ` </Stream>`,
39
+ ` </Connect>`,
40
+ `</Response>`,
41
+ ]
42
+ .filter((line) => line.length > 0)
43
+ .join("\n");
44
+ }
45
+
46
+ /** Speak a short message and hang up — used when realtime isn't available. */
47
+ export function sayAndHangupTwiML(text: string): string {
48
+ return [
49
+ `<?xml version="1.0" encoding="UTF-8"?>`,
50
+ `<Response>`,
51
+ ` <Say>${xmlEscape(text)}</Say>`,
52
+ ` <Hangup/>`,
53
+ `</Response>`,
54
+ ].join("\n");
55
+ }
56
+
57
+ /** Reject the call (Twilio counts this as not-answered — lower cost than answering and hanging up). */
58
+ export function rejectTwiML(): string {
59
+ return [`<?xml version="1.0" encoding="UTF-8"?>`, `<Response>`, ` <Reject/>`, `</Response>`].join("\n");
60
+ }
package/src/cli/index.ts CHANGED
@@ -14,6 +14,7 @@ import { statusCommand } from "./status";
14
14
  import { activeCommand } from "./active";
15
15
  import { modelCommand } from "./model";
16
16
  import { sendCommand, telegramCommand, slackCommand } from "./channels";
17
+ import { phoneCommand } from "./phone";
17
18
  import { rulesCommand, memoryCommand } from "./self";
18
19
  import { watchCommand } from "./watch";
19
20
  import { agentCommand } from "./agent";
@@ -381,6 +382,11 @@ switch (command) {
381
382
  break;
382
383
  }
383
384
 
385
+ case "phone": {
386
+ await phoneCommand();
387
+ break;
388
+ }
389
+
384
390
  case "config": {
385
391
  const configSub = process.argv[3];
386
392
  const configKey = process.argv[4];