@guava-ai/guava-sdk 0.23.0 → 0.25.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/README.md +13 -3
- package/bin/example-runner.ts +138 -11
- package/dist/bin/example-runner.d.ts +23 -6
- package/dist/bin/example-runner.js +112 -9
- package/dist/bin/example-runner.js.map +1 -1
- package/dist/examples/{example.test.js → agent-testing.test.js} +2 -2
- package/dist/examples/agent-testing.test.js.map +1 -0
- package/dist/examples/help-desk.js +22 -8
- package/dist/examples/help-desk.js.map +1 -1
- package/dist/examples/multiple-agents.d.ts +1 -0
- package/dist/examples/multiple-agents.js +52 -0
- package/dist/examples/multiple-agents.js.map +1 -0
- package/dist/examples/property-insurance.js +14 -3
- package/dist/examples/property-insurance.js.map +1 -1
- package/dist/examples/restaurant-waitlist.js +9 -1
- package/dist/examples/restaurant-waitlist.js.map +1 -1
- package/dist/examples/scheduling-outbound.js +5 -6
- package/dist/examples/scheduling-outbound.js.map +1 -1
- package/dist/src/action-item.d.ts +4 -4
- package/dist/src/action-item.js +1 -1
- package/dist/src/action-item.js.map +1 -1
- package/dist/src/agent.d.ts +31 -11
- package/dist/src/agent.js +278 -135
- package/dist/src/agent.js.map +1 -1
- package/dist/src/call.d.ts +12 -1
- package/dist/src/call.js +87 -24
- package/dist/src/call.js.map +1 -1
- package/dist/src/client.d.ts +3 -0
- package/dist/src/client.js +6 -0
- package/dist/src/client.js.map +1 -1
- package/dist/src/commands.d.ts +37 -9
- package/dist/src/commands.js +13 -3
- package/dist/src/commands.js.map +1 -1
- package/dist/src/events.d.ts +28 -1
- package/dist/src/events.js +10 -2
- package/dist/src/events.js.map +1 -1
- package/dist/src/example-utils.d.ts +2 -0
- package/dist/src/example-utils.js +115 -0
- package/dist/src/example-utils.js.map +1 -0
- package/dist/src/health.d.ts +35 -0
- package/dist/src/health.js +99 -0
- package/dist/src/health.js.map +1 -0
- package/dist/src/helpers/index.d.ts +1 -0
- package/dist/src/helpers/index.js +9 -0
- package/dist/src/helpers/index.js.map +1 -0
- package/dist/src/helpers/llm.d.ts +43 -1
- package/dist/src/helpers/llm.js +247 -0
- package/dist/src/helpers/llm.js.map +1 -1
- package/dist/src/helpers/server-rag.d.ts +25 -0
- package/dist/src/helpers/server-rag.js +119 -0
- package/dist/src/helpers/server-rag.js.map +1 -0
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +4 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/runner.d.ts +10 -0
- package/dist/src/runner.js +101 -0
- package/dist/src/runner.js.map +1 -0
- package/dist/src/socket/protocol.d.ts +4 -4
- package/dist/src/testing/mocks.d.ts +6 -0
- package/dist/src/testing/mocks.js +12 -0
- package/dist/src/testing/mocks.js.map +1 -1
- package/dist/src/testing/session.d.ts +7 -0
- package/dist/src/testing/session.js +3 -22
- package/dist/src/testing/session.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/examples/{example.test.ts → agent-testing.test.ts} +1 -1
- package/examples/help-desk.ts +21 -12
- package/examples/multiple-agents.ts +18 -0
- package/examples/property-insurance.ts +15 -3
- package/examples/restaurant-waitlist.ts +10 -1
- package/examples/scheduling-outbound.ts +5 -6
- package/package.json +9 -1
- package/src/action-item.ts +1 -1
- package/src/agent.ts +273 -147
- package/src/call.ts +115 -24
- package/src/client.ts +7 -0
- package/src/commands.ts +16 -2
- package/src/events.ts +11 -1
- package/src/example-utils.ts +87 -0
- package/src/health.ts +120 -0
- package/src/helpers/index.ts +2 -0
- package/src/helpers/llm.ts +170 -1
- package/src/helpers/server-rag.ts +132 -0
- package/src/index.ts +3 -1
- package/src/runner.ts +39 -0
- package/src/testing/mocks.ts +17 -0
- package/src/testing/session.ts +2 -22
- package/src/version.ts +1 -1
- package/dist/examples/example.test.js.map +0 -1
- /package/dist/examples/{example.test.d.ts → agent-testing.test.d.ts} +0 -0
package/src/call.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
TransferCommand,
|
|
10
10
|
ReadScriptCommand,
|
|
11
11
|
RetryTaskCommand,
|
|
12
|
+
SetAgentDTMFCommand,
|
|
12
13
|
} from "./commands.ts";
|
|
13
14
|
import type * as z from "zod";
|
|
14
15
|
import type {
|
|
@@ -20,6 +21,7 @@ import type {
|
|
|
20
21
|
} from "./action-item.ts";
|
|
21
22
|
import { Say } from "./action-item.ts";
|
|
22
23
|
import { telemetryClient } from "./telemetry.ts";
|
|
24
|
+
import type { CallInfo } from "./socket/call-info.ts";
|
|
23
25
|
|
|
24
26
|
export type TaskObjective =
|
|
25
27
|
| { objective: string }
|
|
@@ -31,8 +33,33 @@ export type ReachPersonOutcome = {
|
|
|
31
33
|
nextActionPreview?: string;
|
|
32
34
|
};
|
|
33
35
|
|
|
36
|
+
export const DEFAULT_REACH_PERSON_OUTCOMES: ReachPersonOutcome[] = [
|
|
37
|
+
{ key: "available", description: "The intended contact is confirmed on the line." },
|
|
38
|
+
{
|
|
39
|
+
key: "unavailable",
|
|
40
|
+
description:
|
|
41
|
+
"The contact could not be reached. A third party, gatekeeper, or IVR was unable to transfer the call to the contact.",
|
|
42
|
+
},
|
|
43
|
+
{ key: "voicemail", description: "An answering machine or voicemail system was reached." },
|
|
44
|
+
{ key: "wrong_number", description: "The number does not reach the intended contact." },
|
|
45
|
+
{
|
|
46
|
+
key: "do_not_contact",
|
|
47
|
+
description: "The person on the line has indicated this number should not be called.",
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function voicemailHangupInstruction(): string {
|
|
52
|
+
return "DO NOT leave a message. REMAIN SILENT AND HANG UP WITHOUT RESPONDING.";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function voicemailMessageInstruction(message: string): string {
|
|
56
|
+
return `Say this message VERBATIM: "${message}" Then hang up.`;
|
|
57
|
+
}
|
|
58
|
+
|
|
34
59
|
@telemetryClient.trackClass()
|
|
35
60
|
export class Call {
|
|
61
|
+
private _callId: string;
|
|
62
|
+
private _callInfo: CallInfo;
|
|
36
63
|
protected _commandQueue: Command[] = [];
|
|
37
64
|
private _variables: Record<string, any> = {};
|
|
38
65
|
protected logger: Logger;
|
|
@@ -42,8 +69,17 @@ export class Call {
|
|
|
42
69
|
// input (mutating it) (i.e. _drain should use Array.splice)
|
|
43
70
|
private _drain?: (_: Command[]) => Promise<void>;
|
|
44
71
|
_fieldValues: Record<string, unknown> = {};
|
|
72
|
+
_fieldKeysByTaskId: Record<string, string[]> = {};
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
callId: string,
|
|
76
|
+
callInfo: CallInfo,
|
|
77
|
+
variables: Record<string, any> = {},
|
|
78
|
+
logger: Logger = getDefaultLogger(),
|
|
79
|
+
) {
|
|
80
|
+
this._callId = callId;
|
|
81
|
+
this._callInfo = callInfo;
|
|
45
82
|
|
|
46
|
-
constructor(variables: Record<string, any> = {}, logger: Logger = getDefaultLogger()) {
|
|
47
83
|
// Set initial variables.
|
|
48
84
|
this._variables = { ...variables };
|
|
49
85
|
|
|
@@ -51,6 +87,14 @@ export class Call {
|
|
|
51
87
|
this.logger = logger;
|
|
52
88
|
}
|
|
53
89
|
|
|
90
|
+
get id(): string {
|
|
91
|
+
return this._callId;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get callInfo(): CallInfo {
|
|
95
|
+
return this._callInfo;
|
|
96
|
+
}
|
|
97
|
+
|
|
54
98
|
/**
|
|
55
99
|
* @description Supply a function used to consume commands from the internal command queue.
|
|
56
100
|
*
|
|
@@ -75,6 +119,10 @@ export class Call {
|
|
|
75
119
|
}
|
|
76
120
|
}
|
|
77
121
|
|
|
122
|
+
hasField(key: string): boolean {
|
|
123
|
+
return key in this._fieldValues;
|
|
124
|
+
}
|
|
125
|
+
|
|
78
126
|
async sendCommand<C extends Command, Schema extends z.ZodType<C>>(
|
|
79
127
|
schema: Schema,
|
|
80
128
|
data: z.input<Schema>,
|
|
@@ -84,6 +132,13 @@ export class Call {
|
|
|
84
132
|
await this.flush();
|
|
85
133
|
}
|
|
86
134
|
|
|
135
|
+
async setAgentDtmf(enabled: boolean) {
|
|
136
|
+
await this.sendCommand(SetAgentDTMFCommand, {
|
|
137
|
+
command_type: "set-agent-dtmf",
|
|
138
|
+
enabled,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
87
142
|
async setLanguageMode(args: { primary?: Language; secondary?: Language[] }) {
|
|
88
143
|
await this.sendCommand(SetLanguageModeCommand, {
|
|
89
144
|
command_type: "set-language-mode",
|
|
@@ -128,6 +183,7 @@ export class Call {
|
|
|
128
183
|
throw new Error("At least one of ['objective', 'checklist'] must be provided.");
|
|
129
184
|
}
|
|
130
185
|
|
|
186
|
+
const fieldKeys: string[] = [];
|
|
131
187
|
const action_items = checklist.map((item): ActionItem => {
|
|
132
188
|
if (typeof item === "string") {
|
|
133
189
|
return { item_type: "todo", description: item } satisfies TodoItem;
|
|
@@ -138,12 +194,15 @@ export class Call {
|
|
|
138
194
|
"choiceGenerator is not compatible with the Agent / Call API. Use searchable=true and register a handler.",
|
|
139
195
|
);
|
|
140
196
|
}
|
|
197
|
+
fieldKeys.push(item.key);
|
|
141
198
|
const { choiceGenerator: _, ...fieldData } = item;
|
|
142
199
|
return { ...fieldData, is_search_field: item.searchable } satisfies SerializableFieldItem;
|
|
143
200
|
}
|
|
144
201
|
return item;
|
|
145
202
|
});
|
|
146
203
|
|
|
204
|
+
this._fieldKeysByTaskId[taskId] = fieldKeys;
|
|
205
|
+
|
|
147
206
|
await this.sendCommand(SetTaskCommand, {
|
|
148
207
|
command_type: "set-task",
|
|
149
208
|
task_id: taskId,
|
|
@@ -206,19 +265,38 @@ export class Call {
|
|
|
206
265
|
|
|
207
266
|
async reachPerson(
|
|
208
267
|
contactFullName: string,
|
|
209
|
-
options: {
|
|
268
|
+
options: {
|
|
269
|
+
outcomes?: ReachPersonOutcome[];
|
|
270
|
+
greeting?: string;
|
|
271
|
+
voicemailMessage?: string;
|
|
272
|
+
voicemailHangup?: boolean;
|
|
273
|
+
} = {},
|
|
210
274
|
) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
275
|
+
if ((await this.getVariable("_voicemail_handler")) === "set_voicemail_action") {
|
|
276
|
+
throw new Error(
|
|
277
|
+
"Cannot call reachPerson() after setVoicemailAction(). " +
|
|
278
|
+
"Use the voicemailMessage or voicemailHangup parameters on reachPerson() instead.",
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
await this.setVariable("_voicemail_handler", "reach_person");
|
|
282
|
+
|
|
283
|
+
if (options.voicemailMessage && options.voicemailHangup) {
|
|
284
|
+
throw new Error("Cannot specify both 'voicemailMessage' and 'voicemailHangup'.");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const outcomes = options.outcomes ?? DEFAULT_REACH_PERSON_OUTCOMES;
|
|
288
|
+
|
|
289
|
+
let voicemailRule: string;
|
|
290
|
+
if (options.voicemailHangup) {
|
|
291
|
+
voicemailRule = voicemailHangupInstruction();
|
|
292
|
+
} else if (options.voicemailMessage) {
|
|
293
|
+
voicemailRule = voicemailMessageInstruction(options.voicemailMessage);
|
|
294
|
+
} else {
|
|
295
|
+
voicemailRule = "Leave an appropriate voicemail message.";
|
|
296
|
+
}
|
|
219
297
|
|
|
220
298
|
const availabilityDescription =
|
|
221
|
-
`The availability of ${contactFullName}
|
|
299
|
+
`The availability of ${contactFullName}.` +
|
|
222
300
|
(outcomes.some((o) => o.description)
|
|
223
301
|
? "\nDetailed descriptions of each choice:\n" +
|
|
224
302
|
outcomes
|
|
@@ -230,7 +308,7 @@ export class Call {
|
|
|
230
308
|
const checklist: (FieldItem | SayItem | string)[] = [
|
|
231
309
|
options.greeting !== undefined
|
|
232
310
|
? Say(options.greeting)
|
|
233
|
-
: `Greet the person who answered the phone. Notify them who you are calling on behalf of and the purpose of the call. Ask to speak with ${contactFullName}
|
|
311
|
+
: `Greet the person, IVR, or system who answered the phone. Notify them who you are calling on behalf of and the purpose of the call. Ask to speak with ${contactFullName}. Do not greet if you detect an answering machine or voicemail system.`,
|
|
234
312
|
{
|
|
235
313
|
item_type: "field",
|
|
236
314
|
key: "contact_availability",
|
|
@@ -245,38 +323,51 @@ export class Call {
|
|
|
245
323
|
.map((o) => `- ${o.key} → ${o.nextActionPreview}`);
|
|
246
324
|
if (nextActionLines.length > 0) {
|
|
247
325
|
checklist.push(
|
|
248
|
-
"If a next action is defined below for the value of `contact_availability`, briefly
|
|
326
|
+
"If a next action is defined below for the recorded value of `contact_availability`, briefly let the contact know while you perform it.\n" +
|
|
249
327
|
nextActionLines.join("\n"),
|
|
250
328
|
);
|
|
251
329
|
}
|
|
252
330
|
|
|
253
331
|
const objective = `\
|
|
254
332
|
OBJECTIVE:
|
|
255
|
-
Your goal is to reach ${contactFullName} and
|
|
333
|
+
Your goal is to reach ${contactFullName} and confirm they are on the line.
|
|
256
334
|
|
|
257
335
|
RULES:
|
|
258
|
-
1. If
|
|
259
|
-
- Politely ask to speak with ${contactFullName}
|
|
260
|
-
- Wait to be transferred or for ${contactFullName} to come to the phone
|
|
261
|
-
|
|
336
|
+
1. If someone other than ${contactFullName} answers - including a person or IVR:
|
|
337
|
+
- Politely ask to speak with ${contactFullName}, or navigate menus and prompts to reach them.
|
|
338
|
+
- Wait to be transferred or for ${contactFullName} to come to the phone.
|
|
339
|
+
- If ${contactFullName} cannot be reached, record \`contact_availability\` appropriately.
|
|
340
|
+
2. Once ${contactFullName} is confirmed on the line:
|
|
262
341
|
- Briefly restate who you are and the purpose of your call
|
|
263
|
-
-
|
|
264
|
-
3.
|
|
342
|
+
- Record their availability as available, or equivalent, in \`contact_availability\`.
|
|
343
|
+
3. If it is clearly a wrong number or you have been asked not to call, politely end the call and hang up.
|
|
344
|
+
4. If you reach an answering machine or voicemail: ${voicemailRule}
|
|
345
|
+
`;
|
|
265
346
|
|
|
347
|
+
const completionCriteria = `\
|
|
266
348
|
TASK COMPLETION REQUIREMENTS:
|
|
267
|
-
- The availability of ${contactFullName} must be recorded in \`contact_availability
|
|
349
|
+
- The availability of ${contactFullName} must be recorded in \`contact_availability\`.
|
|
350
|
+
`;
|
|
268
351
|
|
|
269
|
-
await this.setTask({ taskId: "reach_person", objective, checklist });
|
|
352
|
+
await this.setTask({ taskId: "reach_person", objective, checklist, completionCriteria });
|
|
270
353
|
}
|
|
271
354
|
|
|
272
355
|
async setVoicemailAction(action: { hangup: true } | { message: string }) {
|
|
356
|
+
if ((await this.getVariable("_voicemail_handler")) === "reach_person") {
|
|
357
|
+
throw new Error(
|
|
358
|
+
"Cannot call setVoicemailAction() after reachPerson(). " +
|
|
359
|
+
"Use the voicemailMessage or voicemailHangup parameters on reachPerson() instead.",
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
await this.setVariable("_voicemail_handler", "set_voicemail_action");
|
|
363
|
+
|
|
273
364
|
if ("hangup" in action) {
|
|
274
365
|
await this.sendInstruction(
|
|
275
|
-
|
|
366
|
+
`If you encounter an answering machine, ${voicemailHangupInstruction()} You should only do this when it's clear you are unable to reach the person.`,
|
|
276
367
|
);
|
|
277
368
|
} else {
|
|
278
369
|
await this.sendInstruction(
|
|
279
|
-
`If you encounter an answering machine,
|
|
370
|
+
`If you encounter an answering machine, ${voicemailMessageInstruction(action.message)} You should only do this when it's clear you are unable to reach the person.`,
|
|
280
371
|
);
|
|
281
372
|
}
|
|
282
373
|
}
|
package/src/client.ts
CHANGED
|
@@ -172,6 +172,13 @@ export class Client {
|
|
|
172
172
|
return body.webrtc_code;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
async listNumbers(): Promise<{ phoneNumber: string }[]> {
|
|
176
|
+
const url = new URL("v1/phone-numbers", this.getHttpBase());
|
|
177
|
+
const response = await fetchOrThrow(url, { headers: await this.headers() });
|
|
178
|
+
const items = (await response.json()) as { phone_number: string }[];
|
|
179
|
+
return items.map((item) => ({ phoneNumber: item.phone_number }));
|
|
180
|
+
}
|
|
181
|
+
|
|
175
182
|
/**
|
|
176
183
|
* Sends an SMS message from one of your Guava numbers.
|
|
177
184
|
* @param fromNumber - One of your Guava numbers (E.164). Must have SMS configured.
|
package/src/commands.ts
CHANGED
|
@@ -94,14 +94,21 @@ export const RegisteredHooksCommand = z.strictObject({
|
|
|
94
94
|
has_on_question: z.boolean(),
|
|
95
95
|
has_on_intent: z.boolean(),
|
|
96
96
|
has_on_action_requested: z.boolean().optional().default(false),
|
|
97
|
+
has_on_escalate: z.boolean().optional().default(false),
|
|
98
|
+
accept_dtmf_for_numbers: z.boolean().default(true),
|
|
97
99
|
});
|
|
98
100
|
export type RegisteredHooksCommand = z.input<typeof RegisteredHooksCommand>;
|
|
99
101
|
|
|
102
|
+
export const ActionCandidate = z.strictObject({
|
|
103
|
+
key: z.string(),
|
|
104
|
+
description: z.string().default(""),
|
|
105
|
+
});
|
|
106
|
+
export type ActionCandidate = z.input<typeof ActionCandidate>;
|
|
107
|
+
|
|
100
108
|
export const ActionSuggestionCommand = z.strictObject({
|
|
101
109
|
command_type: z.literal("action-suggestion"),
|
|
102
110
|
intent_id: z.string(),
|
|
103
|
-
|
|
104
|
-
action_description: z.string().default(""),
|
|
111
|
+
actions: z.array(ActionCandidate),
|
|
105
112
|
});
|
|
106
113
|
export type ActionSuggestionCommand = z.input<typeof ActionSuggestionCommand>;
|
|
107
114
|
|
|
@@ -120,6 +127,12 @@ export const RetryTaskCommand = z.strictObject({
|
|
|
120
127
|
});
|
|
121
128
|
export type RetryTaskCommand = z.input<typeof RetryTaskCommand>;
|
|
122
129
|
|
|
130
|
+
export const SetAgentDTMFCommand = z.strictObject({
|
|
131
|
+
command_type: z.literal("set-agent-dtmf"),
|
|
132
|
+
enabled: z.boolean(),
|
|
133
|
+
});
|
|
134
|
+
export type SetAgentDTMFCommand = z.input<typeof SetAgentDTMFCommand>;
|
|
135
|
+
|
|
123
136
|
export const AnyCommand = z.union([
|
|
124
137
|
StartOutboundCallCommand,
|
|
125
138
|
ListenInboundCommand,
|
|
@@ -136,6 +149,7 @@ export const AnyCommand = z.union([
|
|
|
136
149
|
ChoiceResultCommand,
|
|
137
150
|
ActionSuggestionCommand,
|
|
138
151
|
RetryTaskCommand,
|
|
152
|
+
SetAgentDTMFCommand,
|
|
139
153
|
]);
|
|
140
154
|
export type Command = z.input<typeof AnyCommand>;
|
|
141
155
|
|
package/src/events.ts
CHANGED
|
@@ -14,12 +14,14 @@ export const InboundCallEvent = z.object({
|
|
|
14
14
|
export type InboundCallEvent = z.infer<typeof InboundCallEvent>;
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* @description The caller has said something.
|
|
17
|
+
* @description The caller has said something. For utterances with the same id,
|
|
18
|
+
* all but the latest is out of date.
|
|
18
19
|
*/
|
|
19
20
|
export const CallerSpeechEvent = z.object({
|
|
20
21
|
event_type: z.literal("caller-speech"),
|
|
21
22
|
|
|
22
23
|
utterance: z.string(),
|
|
24
|
+
utterance_id: z.string().optional(),
|
|
23
25
|
});
|
|
24
26
|
export type CallerSpeechEvent = z.infer<typeof CallerSpeechEvent>;
|
|
25
27
|
|
|
@@ -145,9 +147,16 @@ export type DTMFDigit =
|
|
|
145
147
|
export const DTMFPressedEvent = z.object({
|
|
146
148
|
event_type: z.literal("dtmf"),
|
|
147
149
|
digit: z.enum(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#", "A", "B", "C", "D"]),
|
|
150
|
+
recent_digits: z.string().default(""),
|
|
148
151
|
});
|
|
149
152
|
export type DTMFPressedEvent = z.infer<typeof DTMFPressedEvent>;
|
|
150
153
|
|
|
154
|
+
export const EscalateEvent = z.object({
|
|
155
|
+
event_type: z.literal("escalate"),
|
|
156
|
+
requested_by: z.enum(["human", "agent"]).default("human"),
|
|
157
|
+
});
|
|
158
|
+
export type EscalateEvent = z.infer<typeof EscalateEvent>;
|
|
159
|
+
|
|
151
160
|
export const GuavaEvent = z.discriminatedUnion("event_type", [
|
|
152
161
|
SessionStartedEvent,
|
|
153
162
|
InboundCallEvent,
|
|
@@ -165,6 +174,7 @@ export const GuavaEvent = z.discriminatedUnion("event_type", [
|
|
|
165
174
|
ChoiceQueryEvent,
|
|
166
175
|
ActionRequestEvent,
|
|
167
176
|
ExecuteActionEvent,
|
|
177
|
+
EscalateEvent,
|
|
168
178
|
DTMFPressedEvent,
|
|
169
179
|
]);
|
|
170
180
|
export type GuavaEvent = z.infer<typeof GuavaEvent>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as process from "node:process";
|
|
2
|
+
import { Client } from "./client.ts";
|
|
3
|
+
|
|
4
|
+
export async function getAgentNumber(): Promise<string> {
|
|
5
|
+
if (process.env.GUAVA_AGENT_NUMBER) return process.env.GUAVA_AGENT_NUMBER;
|
|
6
|
+
|
|
7
|
+
const client = new Client();
|
|
8
|
+
const numbers = await client.listNumbers();
|
|
9
|
+
|
|
10
|
+
if (numbers.length === 0) {
|
|
11
|
+
console.error("No agent phone numbers found. Please purchase a number first.");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
if (numbers.length === 1) return numbers[0].phoneNumber;
|
|
15
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
16
|
+
return terminalPicker(
|
|
17
|
+
numbers.map((n) => n.phoneNumber),
|
|
18
|
+
"Multiple agent phone numbers found. Select one",
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
console.error(
|
|
22
|
+
`Multiple agent phone numbers found: ${numbers.map((n) => n.phoneNumber).join(", ")}. Please set GUAVA_AGENT_NUMBER.`,
|
|
23
|
+
);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function terminalPicker(
|
|
28
|
+
options: string[],
|
|
29
|
+
prompt = "Select an option",
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
if (!options.length) throw new Error("options must be non-empty");
|
|
32
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
33
|
+
throw new Error("terminalPicker requires a TTY stdin and stdout");
|
|
34
|
+
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
let selected = 0;
|
|
37
|
+
let renderedLines = 0;
|
|
38
|
+
|
|
39
|
+
function render() {
|
|
40
|
+
const lines = [`${prompt}:`];
|
|
41
|
+
for (let i = 0; i < options.length; i++) {
|
|
42
|
+
lines.push(`${i === selected ? "❯" : " "} ${options[i]}`);
|
|
43
|
+
}
|
|
44
|
+
if (renderedLines > 0) process.stdout.write(`\x1b[${renderedLines}F`);
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
process.stdout.write("\r\x1b[2K");
|
|
47
|
+
process.stdout.write(line);
|
|
48
|
+
process.stdout.write("\n");
|
|
49
|
+
}
|
|
50
|
+
for (let i = 0; i < renderedLines - lines.length; i++) {
|
|
51
|
+
process.stdout.write("\r\x1b[2K\n");
|
|
52
|
+
}
|
|
53
|
+
renderedLines = lines.length;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function cleanup() {
|
|
57
|
+
process.stdin.removeListener("data", onData);
|
|
58
|
+
process.stdin.setRawMode(false);
|
|
59
|
+
process.stdin.pause();
|
|
60
|
+
process.stdout.write("\x1b[?25h");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function onData(data: Buffer) {
|
|
64
|
+
const ch = data.toString();
|
|
65
|
+
if (ch === "\r" || ch === "\n") {
|
|
66
|
+
cleanup();
|
|
67
|
+
process.stdout.write(`\r\x1b[2KSelected: ${options[selected]}\n`);
|
|
68
|
+
resolve(options[selected]);
|
|
69
|
+
} else if (ch === "\x03") {
|
|
70
|
+
cleanup();
|
|
71
|
+
reject(new Error("Interrupted"));
|
|
72
|
+
} else if (ch === "\x1b[A") {
|
|
73
|
+
selected = (selected - 1 + options.length) % options.length;
|
|
74
|
+
render();
|
|
75
|
+
} else if (ch === "\x1b[B") {
|
|
76
|
+
selected = (selected + 1) % options.length;
|
|
77
|
+
render();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
process.stdin.setRawMode(true);
|
|
82
|
+
process.stdin.resume();
|
|
83
|
+
process.stdout.write("\x1b[?25l");
|
|
84
|
+
render();
|
|
85
|
+
process.stdin.on("data", onData);
|
|
86
|
+
});
|
|
87
|
+
}
|
package/src/health.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { type Server, createServer } from "node:http";
|
|
2
|
+
import { getDefaultLogger, type Logger } from "./logging.ts";
|
|
3
|
+
|
|
4
|
+
export type HealthState = "starting" | "live" | "stopped";
|
|
5
|
+
|
|
6
|
+
export class HealthContext {
|
|
7
|
+
private _state: HealthState = "starting";
|
|
8
|
+
|
|
9
|
+
setState(newState: HealthState): void {
|
|
10
|
+
this._state = newState;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
ready(): void {
|
|
14
|
+
this.setState("live");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
stopped(): void {
|
|
18
|
+
this.setState("stopped");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
isLive(): boolean {
|
|
22
|
+
return this._state === "live";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class MultiHealthContext {
|
|
27
|
+
private _ctxs: HealthContext[] = [];
|
|
28
|
+
|
|
29
|
+
createCtx(): HealthContext {
|
|
30
|
+
const ctx = new HealthContext();
|
|
31
|
+
this._ctxs.push(ctx);
|
|
32
|
+
return ctx;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
isLive(): boolean {
|
|
36
|
+
return this._ctxs.length > 0 && this._ctxs.every((ctx) => ctx.isLive());
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HealthServerOptions {
|
|
41
|
+
host?: string;
|
|
42
|
+
port?: number;
|
|
43
|
+
logger?: Logger;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class HealthServer implements AsyncDisposable {
|
|
47
|
+
private readonly _server: Server;
|
|
48
|
+
private readonly _logger: Logger;
|
|
49
|
+
private _stopping: Promise<void> | null = null;
|
|
50
|
+
|
|
51
|
+
private constructor(server: Server, logger: Logger) {
|
|
52
|
+
this._server = server;
|
|
53
|
+
this._logger = logger;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static async start(
|
|
57
|
+
healthCtx: HealthContext | MultiHealthContext,
|
|
58
|
+
options: HealthServerOptions = {},
|
|
59
|
+
): Promise<HealthServer> {
|
|
60
|
+
const host = options.host ?? "0.0.0.0";
|
|
61
|
+
const port = options.port ?? 4828;
|
|
62
|
+
const logger = options.logger ?? getDefaultLogger();
|
|
63
|
+
|
|
64
|
+
const server = createServer((req, res) => {
|
|
65
|
+
if (req.method === "GET" && req.url === "/live") {
|
|
66
|
+
res.statusCode = healthCtx.isLive() ? 200 : 503;
|
|
67
|
+
} else {
|
|
68
|
+
res.statusCode = 404;
|
|
69
|
+
}
|
|
70
|
+
res.end();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await new Promise<void>((resolve, reject) => {
|
|
74
|
+
server.once("error", reject);
|
|
75
|
+
server.listen(port, host, () => {
|
|
76
|
+
server.off("error", reject);
|
|
77
|
+
resolve();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
logger.info("Health check server listening on http://%s:%d/live", host, port);
|
|
82
|
+
return new HealthServer(server, logger);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
stop(): Promise<void> {
|
|
86
|
+
if (this._stopping) return this._stopping;
|
|
87
|
+
this._logger.info("Health check server shutting down.");
|
|
88
|
+
this._stopping = new Promise<void>((resolve, reject) => {
|
|
89
|
+
this._server.closeAllConnections();
|
|
90
|
+
this._server.close((err) => (err ? reject(err) : resolve()));
|
|
91
|
+
});
|
|
92
|
+
return this._stopping;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
96
|
+
await this.stop();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const NOOP_DISPOSABLE: AsyncDisposable = {
|
|
101
|
+
async [Symbol.asyncDispose]() {},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function isEnabled(): boolean {
|
|
105
|
+
const value = (process.env.GUAVA_HEALTH_SERVER ?? "false").toLowerCase().trim();
|
|
106
|
+
return value === "yes" || value === "true" || value === "on";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Returns an AsyncDisposable that, when the GUAVA_HEALTH_SERVER env var is
|
|
111
|
+
* truthy ("yes", "true", "on"), starts an HTTP health check server exposing
|
|
112
|
+
* `/live` on port 4828. Otherwise returns a no-op disposable. Intended for use
|
|
113
|
+
* with `await using`.
|
|
114
|
+
*/
|
|
115
|
+
export async function getHealthServer(
|
|
116
|
+
healthCtx: HealthContext | MultiHealthContext,
|
|
117
|
+
): Promise<AsyncDisposable> {
|
|
118
|
+
if (!isEnabled()) return NOOP_DISPOSABLE;
|
|
119
|
+
return HealthServer.start(healthCtx);
|
|
120
|
+
}
|