@guava-ai/guava-sdk 0.25.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/src/agent.d.ts +4 -1
- package/dist/src/agent.js +97 -27
- package/dist/src/agent.js.map +1 -1
- package/dist/src/call.d.ts +2 -0
- package/dist/src/call.js +18 -0
- package/dist/src/call.js.map +1 -1
- package/dist/src/client.d.ts +0 -22
- package/dist/src/client.js +0 -155
- package/dist/src/client.js.map +1 -1
- package/dist/src/commands.d.ts +73 -0
- package/dist/src/commands.js +12 -1
- package/dist/src/commands.js.map +1 -1
- package/dist/src/events.d.ts +2 -1
- package/dist/src/events.js +20 -2
- package/dist/src/events.js.map +1 -1
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.js +1 -3
- package/dist/src/index.js.map +1 -1
- package/dist/src/socket/client.js +10 -0
- package/dist/src/socket/client.js.map +1 -1
- package/dist/src/telemetry.js +1 -1
- package/dist/src/telemetry.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/examples/README.md +7 -6
- package/package.json +1 -1
- package/src/agent.ts +117 -26
- package/src/call.ts +25 -0
- package/src/client.ts +0 -197
- package/src/commands.ts +15 -0
- package/src/events.ts +20 -18
- package/src/index.ts +0 -1
- package/src/socket/client.ts +22 -0
- package/src/telemetry.ts +1 -1
- package/src/version.ts +1 -1
- package/dist/src/call-controller.d.ts +0 -137
- package/dist/src/call-controller.js +0 -436
- package/dist/src/call-controller.js.map +0 -1
- package/src/call-controller.ts +0 -451
package/examples/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Examples
|
|
2
2
|
|
|
3
|
-
- [
|
|
4
|
-
- [help-desk.ts](./help-desk.ts) -
|
|
5
|
-
- [
|
|
6
|
-
- [
|
|
7
|
-
- [
|
|
8
|
-
- [
|
|
3
|
+
- [agent-testing.test.ts](./agent-testing.test.ts) - Test suite for the help-desk agent demonstrating `agent.test()`, `agent.testRoleplay()`, `agent.patch()`, handler unit tests with `MockCall`, and LLM-evaluated assertions with `session.evaluate()`.
|
|
4
|
+
- [help-desk.ts](./help-desk.ts) - Inbound call agent for a furniture retailer that uses `DocumentQA` for RAG-backed question answering and `IntentRecognizer` to route callers to the correct department (sales, delivery, account management).
|
|
5
|
+
- [multiple-agents.ts](./multiple-agents.ts) - Demonstrates running two independent agents simultaneously on different connection types (phone and WebRTC) using a shared `Runner`.
|
|
6
|
+
- [polling-campaign.ts](./polling-campaign.ts) - Outbound campaign agent that conducts a non-partisan political opinion poll, uses `reachPerson` to confirm contact availability, and collects structured responses via a task checklist.
|
|
7
|
+
- [property-insurance.ts](./property-insurance.ts) - Inbound call agent for a property insurance company that uses `DocumentQA` to answer policy questions from a knowledge base.
|
|
8
|
+
- [restaurant-waitlist.ts](./restaurant-waitlist.ts) - Inbound call agent for a restaurant that collects caller name, party size, and phone number to add callers to a waitlist using a structured task.
|
|
9
|
+
- [scheduling-outbound.ts](./scheduling-outbound.ts) - Outbound call agent for a dental office that uses `reachPerson` to confirm contact availability, then schedules appointments using a searchable calendar-slot field backed by `DatetimeFilter`.
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@guava-ai/guava-sdk",
|
|
3
3
|
"description": "The official TypeScript SDK for building Guava voice agents.",
|
|
4
4
|
"homepage": "https://docs.goguava.ai/typescript-sdk/",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.27.0",
|
|
6
6
|
"type": "commonjs",
|
|
7
7
|
"main": "dist/src/index.js",
|
|
8
8
|
"types": "dist/src/index.d.ts",
|
package/src/agent.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
AnswerQuestionCommand,
|
|
5
5
|
ChoiceResultCommand,
|
|
6
6
|
type Command,
|
|
7
|
+
ExpertErrorCommand,
|
|
7
8
|
RegisteredHooksCommand,
|
|
8
9
|
} from "./commands.ts";
|
|
9
10
|
import {
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
type DTMFPressedEvent,
|
|
14
15
|
type EscalateEvent,
|
|
15
16
|
type GuavaEvent,
|
|
17
|
+
type OutboundCallFailed,
|
|
16
18
|
decodeEventDict,
|
|
17
19
|
} from "./events.ts";
|
|
18
20
|
import {
|
|
@@ -91,6 +93,7 @@ export class Agent {
|
|
|
91
93
|
private _onSessionEnd?: (call: Call, event: BotSessionEnded) => Promise<void>;
|
|
92
94
|
private _onEscalate?: (call: Call, event: EscalateEvent) => Promise<void>;
|
|
93
95
|
private _onDtmf?: (call: Call, event: DTMFPressedEvent) => Promise<void>;
|
|
96
|
+
private _onOutboundFailed?: (event: OutboundCallFailed) => Promise<void>;
|
|
94
97
|
|
|
95
98
|
constructor(args?: {
|
|
96
99
|
name?: string;
|
|
@@ -194,6 +197,10 @@ export class Agent {
|
|
|
194
197
|
this._onDtmf = callback;
|
|
195
198
|
}
|
|
196
199
|
|
|
200
|
+
onOutboundFailed(callback: (event: OutboundCallFailed) => Promise<void>): void {
|
|
201
|
+
this._onOutboundFailed = callback;
|
|
202
|
+
}
|
|
203
|
+
|
|
197
204
|
get handlers() {
|
|
198
205
|
return {
|
|
199
206
|
onCallReceived: (callInfo: CallInfo) => this._onCallReceived(callInfo),
|
|
@@ -278,14 +285,37 @@ export class Agent {
|
|
|
278
285
|
await runWebrtcHelper(webrtcCode, getBaseUrl());
|
|
279
286
|
}
|
|
280
287
|
|
|
288
|
+
private async _invokeHandler(
|
|
289
|
+
call: Call,
|
|
290
|
+
name: string,
|
|
291
|
+
informAgent: boolean,
|
|
292
|
+
handler: () => Promise<void>,
|
|
293
|
+
): Promise<void> {
|
|
294
|
+
try {
|
|
295
|
+
await handler();
|
|
296
|
+
} catch (err) {
|
|
297
|
+
this._logger.error(`An error occurred in the ${name} handler.`, err);
|
|
298
|
+
if (informAgent) {
|
|
299
|
+
await call.sendCommand(ExpertErrorCommand, {
|
|
300
|
+
command_type: "expert-error",
|
|
301
|
+
message: `The expert encountered an error while processing ${name}`,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
281
307
|
private async _dispatchEvent(call: Call, event: GuavaEvent, testSession?: TestSession) {
|
|
282
308
|
if (event.event_type === "caller-speech") {
|
|
283
309
|
if (this._onCallerSpeech !== undefined) {
|
|
284
|
-
await this.
|
|
310
|
+
await this._invokeHandler(call, "onCallerSpeech", false, () =>
|
|
311
|
+
this._onCallerSpeech!(call, event),
|
|
312
|
+
);
|
|
285
313
|
}
|
|
286
314
|
} else if (event.event_type === "agent-speech") {
|
|
287
315
|
if (this._onAgentSpeech !== undefined) {
|
|
288
|
-
await this.
|
|
316
|
+
await this._invokeHandler(call, "onAgentSpeech", false, () =>
|
|
317
|
+
this._onAgentSpeech!(call, event),
|
|
318
|
+
);
|
|
289
319
|
}
|
|
290
320
|
} else if (event.event_type === "task-done") {
|
|
291
321
|
const errors: string[] = [];
|
|
@@ -304,17 +334,28 @@ export class Agent {
|
|
|
304
334
|
await call.retryTask(errors.join(" "));
|
|
305
335
|
} else {
|
|
306
336
|
this._logger.info(`Task ${event.task_id} completed.`);
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
337
|
+
try {
|
|
338
|
+
if (this._onTaskCompleteGeneric !== undefined) {
|
|
339
|
+
await this._onTaskCompleteGeneric(call, event.task_id);
|
|
340
|
+
} else if (event.task_id in this._onTaskCompleteHandlers) {
|
|
341
|
+
await this._onTaskCompleteHandlers[event.task_id](call);
|
|
342
|
+
} else {
|
|
343
|
+
this._logger.warn(`No handler registered for completion of task '${event.task_id}'`);
|
|
344
|
+
}
|
|
345
|
+
} catch (err) {
|
|
346
|
+
this._logger.error(
|
|
347
|
+
`An error occurred in the on_task_complete('${event.task_id}') handler.`,
|
|
348
|
+
err,
|
|
349
|
+
);
|
|
350
|
+
await call.sendCommand(ExpertErrorCommand, {
|
|
351
|
+
command_type: "expert-error",
|
|
352
|
+
message: `The expert encountered an error while processing on_task_complete('${event.task_id}') - the task has failed.`,
|
|
353
|
+
});
|
|
313
354
|
}
|
|
314
355
|
}
|
|
315
356
|
} else if (event.event_type === "agent-question") {
|
|
357
|
+
this._logger.info(`Received question from bot: ${event.question}`);
|
|
316
358
|
if (this._onQuestion !== undefined) {
|
|
317
|
-
this._logger.info(`Received question from bot: ${event.question}`);
|
|
318
359
|
let answer: string;
|
|
319
360
|
try {
|
|
320
361
|
answer = await this._onQuestion(call, event.question);
|
|
@@ -339,6 +380,9 @@ export class Agent {
|
|
|
339
380
|
}
|
|
340
381
|
} else if (event.event_type === "action-item-done") {
|
|
341
382
|
call._fieldValues[event.key] = event.payload;
|
|
383
|
+
if (event.key && event.payload) {
|
|
384
|
+
this._logger.info(`Field ${event.key} updated.`);
|
|
385
|
+
}
|
|
342
386
|
} else if (event.event_type === "choice-query") {
|
|
343
387
|
this._logger.info(`Received search query for field '${event.field_key}': ${event.query}`);
|
|
344
388
|
const handler = this._searchQueryHandlers[event.field_key];
|
|
@@ -346,25 +390,49 @@ export class Agent {
|
|
|
346
390
|
this._logger.warn(
|
|
347
391
|
`Search query arrived for field '${event.field_key}' with no handler attached.`,
|
|
348
392
|
);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
command_type: "choice-query-result",
|
|
353
|
-
field_key: event.field_key,
|
|
354
|
-
query_id: event.query_id,
|
|
355
|
-
matched_choices: matchedChoices,
|
|
356
|
-
other_choices: otherChoices,
|
|
393
|
+
await call.sendCommand(ExpertErrorCommand, {
|
|
394
|
+
command_type: "expert-error",
|
|
395
|
+
message: `The expert failed to handle on_search_query('${event.field_key}'). No results will be forthcoming.`,
|
|
357
396
|
});
|
|
397
|
+
} else {
|
|
398
|
+
try {
|
|
399
|
+
const [matchedChoices, otherChoices] = await handler(call, event.query);
|
|
400
|
+
await call.sendCommand(ChoiceResultCommand, {
|
|
401
|
+
command_type: "choice-query-result",
|
|
402
|
+
field_key: event.field_key,
|
|
403
|
+
query_id: event.query_id,
|
|
404
|
+
matched_choices: matchedChoices,
|
|
405
|
+
other_choices: otherChoices,
|
|
406
|
+
});
|
|
407
|
+
} catch (err) {
|
|
408
|
+
this._logger.error(
|
|
409
|
+
`An error occurred in the on_search_query('${event.field_key}') handler.`,
|
|
410
|
+
err,
|
|
411
|
+
);
|
|
412
|
+
await call.sendCommand(ExpertErrorCommand, {
|
|
413
|
+
command_type: "expert-error",
|
|
414
|
+
message: `The expert encountered an error while processing the on_search_query('${event.field_key}') handler. No results will be forthcoming.`,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
358
417
|
}
|
|
359
418
|
} else if (event.event_type === "action-request") {
|
|
360
419
|
this._logger.info(`Received action request ${event.intent_id}: ${event.intent_summary}`);
|
|
361
420
|
let suggestions: SuggestedAction[] = [];
|
|
362
421
|
if (this._onActionRequested !== undefined) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
422
|
+
try {
|
|
423
|
+
const result = await this._onActionRequested(call, event.intent_summary);
|
|
424
|
+
if (Array.isArray(result)) {
|
|
425
|
+
suggestions = result;
|
|
426
|
+
} else if (result !== null && result !== undefined) {
|
|
427
|
+
suggestions = [result];
|
|
428
|
+
}
|
|
429
|
+
} catch (err) {
|
|
430
|
+
this._logger.error("An error occurred in the onActionRequest handler.", err);
|
|
431
|
+
await call.sendCommand(ExpertErrorCommand, {
|
|
432
|
+
command_type: "expert-error",
|
|
433
|
+
message:
|
|
434
|
+
"The expert encountered an error while processing the on_action_request handler.",
|
|
435
|
+
});
|
|
368
436
|
}
|
|
369
437
|
}
|
|
370
438
|
await call.sendCommand(ActionSuggestionCommand, {
|
|
@@ -384,7 +452,18 @@ export class Agent {
|
|
|
384
452
|
onActionFunc = () => this._onActionHandlers[event.action_key](call);
|
|
385
453
|
}
|
|
386
454
|
if (onActionFunc !== undefined) {
|
|
387
|
-
|
|
455
|
+
try {
|
|
456
|
+
await onActionFunc();
|
|
457
|
+
} catch (err) {
|
|
458
|
+
this._logger.error(
|
|
459
|
+
`An error occurred in the on_action('${event.action_key}') handler.`,
|
|
460
|
+
err,
|
|
461
|
+
);
|
|
462
|
+
await call.sendCommand(ExpertErrorCommand, {
|
|
463
|
+
command_type: "expert-error",
|
|
464
|
+
message: `The expert encountered an error while processing the on_action('${event.action_key}') handler.`,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
388
467
|
} else {
|
|
389
468
|
this._logger.warn(`No handler registered for action '${event.action_key}'`);
|
|
390
469
|
}
|
|
@@ -393,14 +472,18 @@ export class Agent {
|
|
|
393
472
|
if (testSession) {
|
|
394
473
|
testSession.terminationReason = event.termination_reason;
|
|
395
474
|
}
|
|
396
|
-
|
|
475
|
+
if (this._onSessionEnd !== undefined) {
|
|
476
|
+
await this._invokeHandler(call, "onSessionEnd", false, () =>
|
|
477
|
+
this._onSessionEnd!(call, event),
|
|
478
|
+
);
|
|
479
|
+
}
|
|
397
480
|
} else if (event.event_type === "dtmf") {
|
|
398
481
|
if (this._onDtmf !== undefined) {
|
|
399
|
-
await this._onDtmf(call, event);
|
|
482
|
+
await this._invokeHandler(call, "onDtmf", false, () => this._onDtmf!(call, event));
|
|
400
483
|
}
|
|
401
484
|
} else if (event.event_type === "escalate") {
|
|
402
485
|
if (this._onEscalate !== undefined) {
|
|
403
|
-
await this._onEscalate(call, event);
|
|
486
|
+
await this._invokeHandler(call, "onEscalate", true, () => this._onEscalate!(call, event));
|
|
404
487
|
} else if (event.requested_by === "agent") {
|
|
405
488
|
await call.sendInstruction(
|
|
406
489
|
"No escalation target set. Apologize for not being able to help, ask them to try calling another time, and hang up the call immediately.",
|
|
@@ -410,6 +493,13 @@ export class Agent {
|
|
|
410
493
|
"Let them know there are no representatives available to take their call. Ask them if they would prefer to continue or to call another time.",
|
|
411
494
|
);
|
|
412
495
|
}
|
|
496
|
+
} else if (event.event_type === "outbound-call-failed") {
|
|
497
|
+
this._logger.error(`Outbound call failed: ${event.error_reason}`);
|
|
498
|
+
if (this._onOutboundFailed !== undefined) {
|
|
499
|
+
await this._invokeHandler(call, "onOutboundFailed", false, () =>
|
|
500
|
+
this._onOutboundFailed!(event),
|
|
501
|
+
);
|
|
502
|
+
}
|
|
413
503
|
} else if (event.event_type === "error") {
|
|
414
504
|
this._logger.error(`The Guava agent reported an error: ${event.content}`);
|
|
415
505
|
} else if (event.event_type === "warning") {
|
|
@@ -796,6 +886,7 @@ Choose "speak" and provide your next utterance, or choose "hangup" if the conver
|
|
|
796
886
|
cloned._onSessionEnd = this._onSessionEnd;
|
|
797
887
|
cloned._onEscalate = this._onEscalate;
|
|
798
888
|
cloned._onDtmf = this._onDtmf;
|
|
889
|
+
cloned._onOutboundFailed = this._onOutboundFailed;
|
|
799
890
|
return cloned;
|
|
800
891
|
}
|
|
801
892
|
|
package/src/call.ts
CHANGED
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
ReadScriptCommand,
|
|
11
11
|
RetryTaskCommand,
|
|
12
12
|
SetAgentDTMFCommand,
|
|
13
|
+
SendAgentDTMFCommand,
|
|
13
14
|
} from "./commands.ts";
|
|
15
|
+
import { DTMF_DIGITS, type DTMFDigit } from "./events.ts";
|
|
14
16
|
import type * as z from "zod";
|
|
15
17
|
import type {
|
|
16
18
|
ActionItem,
|
|
@@ -133,12 +135,35 @@ export class Call {
|
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
async setAgentDtmf(enabled: boolean) {
|
|
138
|
+
if (this._callInfo.call_type === "webrtc") {
|
|
139
|
+
throw new Error("WebRTC calls do not support sending DTMF.");
|
|
140
|
+
}
|
|
136
141
|
await this.sendCommand(SetAgentDTMFCommand, {
|
|
137
142
|
command_type: "set-agent-dtmf",
|
|
138
143
|
enabled,
|
|
139
144
|
});
|
|
140
145
|
}
|
|
141
146
|
|
|
147
|
+
async sendDtmf(digits: DTMFDigit[] | string) {
|
|
148
|
+
if (this._callInfo.call_type === "webrtc") {
|
|
149
|
+
throw new Error("WebRTC calls do not support sending DTMF.");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const digitsList = (typeof digits === "string" ? [...digits] : digits) as DTMFDigit[];
|
|
153
|
+
|
|
154
|
+
const validSet = new Set<string>(DTMF_DIGITS);
|
|
155
|
+
if (!digitsList.every((d) => validSet.has(d))) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Please input a valid set of DTMF digits. The valid DTMF digits are: ${JSON.stringify(DTMF_DIGITS)}.`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await this.sendCommand(SendAgentDTMFCommand, {
|
|
162
|
+
command_type: "send-agent-dtmf",
|
|
163
|
+
digits: digitsList,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
142
167
|
async setLanguageMode(args: { primary?: Language; secondary?: Language[] }) {
|
|
143
168
|
await this.sendCommand(SetLanguageModeCommand, {
|
|
144
169
|
command_type: "set-language-mode",
|
package/src/client.ts
CHANGED
|
@@ -1,19 +1,10 @@
|
|
|
1
|
-
import WebSocket from "ws";
|
|
2
1
|
import { type Logger, getDefaultLogger } from "./logging.ts";
|
|
3
|
-
import {
|
|
4
|
-
StartOutboundCallCommand,
|
|
5
|
-
ListenInboundCommand,
|
|
6
|
-
InboundTunnelCommand,
|
|
7
|
-
} from "./commands.ts";
|
|
8
|
-
import * as z from "zod";
|
|
9
|
-
import { ErrorEvent, SessionStartedEvent, decodeEvent, InboundTunnelEvent } from "./events.ts";
|
|
10
2
|
import { SDK_VERSION } from "./version.ts";
|
|
11
3
|
import os from "node:os";
|
|
12
4
|
import * as fs from "node:fs";
|
|
13
5
|
import { getBaseUrl, fetchOrThrow, sleep } from "./utils.ts";
|
|
14
6
|
import { SmsMessage } from "./sms.ts";
|
|
15
7
|
import { telemetryClient } from "./telemetry.ts";
|
|
16
|
-
import type { CallController } from "./call-controller.ts";
|
|
17
8
|
import {
|
|
18
9
|
type AuthStrategy,
|
|
19
10
|
APIKeyAuth,
|
|
@@ -35,10 +26,6 @@ export interface ClientOptions {
|
|
|
35
26
|
|
|
36
27
|
let firstClient = false;
|
|
37
28
|
|
|
38
|
-
function stringifyZod<Schema extends z.ZodType>(schema: Schema, data: z.input<Schema>): string {
|
|
39
|
-
return JSON.stringify(schema.parse(data));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
29
|
export type InboundConnection = { agent_number: string } | { webrtc_code: string };
|
|
43
30
|
|
|
44
31
|
const http_start = /^http:\/\//;
|
|
@@ -49,9 +36,6 @@ export class Client {
|
|
|
49
36
|
private _auth: AuthStrategy;
|
|
50
37
|
private _baseUrl: string;
|
|
51
38
|
private _logger: Logger;
|
|
52
|
-
private _ws?: WebSocket;
|
|
53
|
-
private _controller?: CallController;
|
|
54
|
-
private messageHandler?: (_: WebSocket.MessageEvent) => void;
|
|
55
39
|
|
|
56
40
|
constructor({
|
|
57
41
|
apiKey,
|
|
@@ -245,185 +229,4 @@ export class Client {
|
|
|
245
229
|
await sleep(Math.min(pollIntervalMs, remaining));
|
|
246
230
|
}
|
|
247
231
|
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* @description use the Guava API to call out to a number
|
|
251
|
-
*/
|
|
252
|
-
async createOutbound(
|
|
253
|
-
fromNumber: string | undefined,
|
|
254
|
-
toNumber: string,
|
|
255
|
-
callController: CallController,
|
|
256
|
-
) {
|
|
257
|
-
const url = new URL("v1/create-outbound", this.getWebsocketBase());
|
|
258
|
-
const ws = new WebSocket(url, {
|
|
259
|
-
headers: await this.headers(),
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
ws.addEventListener("open", async (_ev) => {
|
|
263
|
-
ws.send(
|
|
264
|
-
stringifyZod(StartOutboundCallCommand, {
|
|
265
|
-
command_type: "start-outbound",
|
|
266
|
-
to_number: toNumber,
|
|
267
|
-
from_number: fromNumber,
|
|
268
|
-
}),
|
|
269
|
-
);
|
|
270
|
-
|
|
271
|
-
// set the callController drain function to send all commands
|
|
272
|
-
// through the now open websocket
|
|
273
|
-
callController.setDrain(async (commands) => {
|
|
274
|
-
for (const command of commands.splice(0)) {
|
|
275
|
-
this._logger.debug(`Sending command ${JSON.stringify(command)}`);
|
|
276
|
-
ws.send(JSON.stringify(command));
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
await callController.onCallStart();
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
ws.addEventListener("close", (_ev) => {
|
|
284
|
-
// we are closing the socket, so don't trigger any other listeners
|
|
285
|
-
ws.removeAllListeners();
|
|
286
|
-
this._ws = undefined;
|
|
287
|
-
this._controller = undefined;
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
this._ws = ws;
|
|
291
|
-
this._controller = callController;
|
|
292
|
-
this.replaceHandler(this.uninitializedOutbound.bind(this));
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
private replaceHandler(newHandler?: (_: WebSocket.MessageEvent) => void) {
|
|
296
|
-
if (this.messageHandler) {
|
|
297
|
-
this._ws?.removeEventListener("message", this.messageHandler);
|
|
298
|
-
}
|
|
299
|
-
if (newHandler) {
|
|
300
|
-
this._ws?.addEventListener("message", newHandler);
|
|
301
|
-
}
|
|
302
|
-
this.messageHandler = newHandler;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// eventlistener handlers for server events
|
|
306
|
-
// (a state machine in functions)
|
|
307
|
-
private uninitializedOutbound(ev: WebSocket.MessageEvent) {
|
|
308
|
-
// for correctness (and type correctness)
|
|
309
|
-
if (!this._ws) {
|
|
310
|
-
throw new Error("[internal] Uninitialized WebSocket");
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const session_started = z
|
|
314
|
-
.union([SessionStartedEvent, ErrorEvent])
|
|
315
|
-
.parse(JSON.parse(ev.data.toString("utf8")));
|
|
316
|
-
if (session_started.event_type === "error") {
|
|
317
|
-
throw new Error(`Outbound call failed: ${session_started.content}`);
|
|
318
|
-
} else {
|
|
319
|
-
this._logger.info(`Started session with ID: ${session_started.session_id}`);
|
|
320
|
-
// move to next state
|
|
321
|
-
this.replaceHandler(this.initializedOutbound.bind(this));
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
private async initializedOutbound(ev: WebSocket.MessageEvent) {
|
|
326
|
-
// for correctness (and type correctness)
|
|
327
|
-
if (!this._ws) {
|
|
328
|
-
throw new Error("[internal] Uninitialized WebSocket");
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// handle the received event
|
|
332
|
-
const event = decodeEvent(ev.data);
|
|
333
|
-
if (event) {
|
|
334
|
-
if (this._controller) {
|
|
335
|
-
await this._controller.onEvent(event);
|
|
336
|
-
}
|
|
337
|
-
if (event.event_type === "outbound-call-failed" || event.event_type === "bot-session-ended") {
|
|
338
|
-
// shutdown the websocket
|
|
339
|
-
this._ws.close();
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* @description use the Guava API to receive calls at a given number
|
|
346
|
-
*/
|
|
347
|
-
async listenInbound<U extends CallController>(
|
|
348
|
-
conn: InboundConnection,
|
|
349
|
-
controllerClassFactory: (logger: Logger) => U,
|
|
350
|
-
): Promise<InboundListener> {
|
|
351
|
-
const callControllers: Record<string, U> = {};
|
|
352
|
-
|
|
353
|
-
// return a way to *stop* listening
|
|
354
|
-
const url = new URL("v1/listen-inbound", this.getWebsocketBase());
|
|
355
|
-
const ws = new WebSocket(url, {
|
|
356
|
-
headers: await this.headers(),
|
|
357
|
-
});
|
|
358
|
-
let agent_number: string | undefined;
|
|
359
|
-
let webrtc_code: string | undefined;
|
|
360
|
-
if ("agent_number" in conn) {
|
|
361
|
-
agent_number = conn.agent_number;
|
|
362
|
-
} else {
|
|
363
|
-
webrtc_code = conn.webrtc_code;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
this._logger.info(`Listening for calls to ${agent_number ?? webrtc_code}`);
|
|
367
|
-
|
|
368
|
-
if (webrtc_code) {
|
|
369
|
-
const debugurl = new URL(`debug-webrtc?webrtc_code=${webrtc_code}`, this.getHttpBase());
|
|
370
|
-
this._logger.debug(`WebRTC DebugURL: ${debugurl}`);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
ws.addEventListener("open", (_ev) => {
|
|
374
|
-
ws.send(
|
|
375
|
-
stringifyZod(ListenInboundCommand, {
|
|
376
|
-
command_type: "listen-inbound",
|
|
377
|
-
agent_number: agent_number,
|
|
378
|
-
webrtc_code: webrtc_code,
|
|
379
|
-
}),
|
|
380
|
-
);
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
ws.addEventListener("close", (_ev) => {
|
|
384
|
-
ws.removeAllListeners();
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
ws.addEventListener("message", (ev) => {
|
|
388
|
-
const tunnel_event = InboundTunnelEvent.parse(JSON.parse(ev.data.toString("utf8")));
|
|
389
|
-
if (!(tunnel_event.call_id in callControllers)) {
|
|
390
|
-
this._logger.info(
|
|
391
|
-
`Received tunnel event for new call ID: ${tunnel_event.call_id}. Creating call controller.`,
|
|
392
|
-
);
|
|
393
|
-
|
|
394
|
-
const newController = controllerClassFactory(this._logger);
|
|
395
|
-
newController.setDrain(async (commands) => {
|
|
396
|
-
for (const command of commands.splice(0)) {
|
|
397
|
-
this._logger.debug(
|
|
398
|
-
`Sending command: ${JSON.stringify(command)} for call ID: ${tunnel_event.call_id}`,
|
|
399
|
-
);
|
|
400
|
-
ws.send(
|
|
401
|
-
stringifyZod(InboundTunnelCommand, {
|
|
402
|
-
call_id: tunnel_event.call_id,
|
|
403
|
-
command,
|
|
404
|
-
}),
|
|
405
|
-
);
|
|
406
|
-
}
|
|
407
|
-
});
|
|
408
|
-
callControllers[tunnel_event.call_id] = newController;
|
|
409
|
-
newController.onEvent(tunnel_event.event);
|
|
410
|
-
} else {
|
|
411
|
-
// no threading, so manually forward to onEvent!
|
|
412
|
-
callControllers[tunnel_event.call_id].onEvent(tunnel_event.event);
|
|
413
|
-
}
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
return new InboundListener(ws);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
class InboundListener {
|
|
421
|
-
private ws: WebSocket;
|
|
422
|
-
constructor(ws: WebSocket) {
|
|
423
|
-
this.ws = ws;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
close() {
|
|
427
|
-
this.ws.close();
|
|
428
|
-
}
|
|
429
232
|
}
|
package/src/commands.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
import { ActionItem } from "./action-item.ts";
|
|
3
|
+
import { DTMF_DIGITS } from "./events.ts";
|
|
3
4
|
|
|
4
5
|
export const StartOutboundCallCommand = z.strictObject({
|
|
5
6
|
command_type: z.literal("start-outbound"),
|
|
@@ -133,6 +134,18 @@ export const SetAgentDTMFCommand = z.strictObject({
|
|
|
133
134
|
});
|
|
134
135
|
export type SetAgentDTMFCommand = z.input<typeof SetAgentDTMFCommand>;
|
|
135
136
|
|
|
137
|
+
export const SendAgentDTMFCommand = z.strictObject({
|
|
138
|
+
command_type: z.literal("send-agent-dtmf"),
|
|
139
|
+
digits: z.array(z.enum(DTMF_DIGITS)),
|
|
140
|
+
});
|
|
141
|
+
export type SendAgentDTMFCommand = z.input<typeof SendAgentDTMFCommand>;
|
|
142
|
+
|
|
143
|
+
export const ExpertErrorCommand = z.strictObject({
|
|
144
|
+
command_type: z.literal("expert-error"),
|
|
145
|
+
message: z.string(),
|
|
146
|
+
});
|
|
147
|
+
export type ExpertErrorCommand = z.input<typeof ExpertErrorCommand>;
|
|
148
|
+
|
|
136
149
|
export const AnyCommand = z.union([
|
|
137
150
|
StartOutboundCallCommand,
|
|
138
151
|
ListenInboundCommand,
|
|
@@ -150,6 +163,8 @@ export const AnyCommand = z.union([
|
|
|
150
163
|
ActionSuggestionCommand,
|
|
151
164
|
RetryTaskCommand,
|
|
152
165
|
SetAgentDTMFCommand,
|
|
166
|
+
SendAgentDTMFCommand,
|
|
167
|
+
ExpertErrorCommand,
|
|
153
168
|
]);
|
|
154
169
|
export type Command = z.input<typeof AnyCommand>;
|
|
155
170
|
|
package/src/events.ts
CHANGED
|
@@ -126,27 +126,29 @@ export const ExecuteActionEvent = z.object({
|
|
|
126
126
|
});
|
|
127
127
|
export type ExecuteActionEvent = z.infer<typeof ExecuteActionEvent>;
|
|
128
128
|
|
|
129
|
-
export
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
129
|
+
export const DTMF_DIGITS = [
|
|
130
|
+
"0",
|
|
131
|
+
"1",
|
|
132
|
+
"2",
|
|
133
|
+
"3",
|
|
134
|
+
"4",
|
|
135
|
+
"5",
|
|
136
|
+
"6",
|
|
137
|
+
"7",
|
|
138
|
+
"8",
|
|
139
|
+
"9",
|
|
140
|
+
"*",
|
|
141
|
+
"#",
|
|
142
|
+
"A",
|
|
143
|
+
"B",
|
|
144
|
+
"C",
|
|
145
|
+
"D",
|
|
146
|
+
] as const;
|
|
147
|
+
export type DTMFDigit = (typeof DTMF_DIGITS)[number];
|
|
146
148
|
|
|
147
149
|
export const DTMFPressedEvent = z.object({
|
|
148
150
|
event_type: z.literal("dtmf"),
|
|
149
|
-
digit: z.enum(
|
|
151
|
+
digit: z.enum(DTMF_DIGITS),
|
|
150
152
|
recent_digits: z.string().default(""),
|
|
151
153
|
});
|
|
152
154
|
export type DTMFPressedEvent = z.infer<typeof DTMFPressedEvent>;
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export { Client, type InboundConnection } from "./client.ts";
|
|
2
2
|
export { Runner } from "./runner.ts";
|
|
3
3
|
export type { SmsMessage } from "./sms.ts";
|
|
4
|
-
export { CallController, type TaskObjective } from "./call-controller.ts";
|
|
5
4
|
export { Say, Field } from "./action-item.ts";
|
|
6
5
|
export { Logger, getConsoleLogger, getDefaultLogger } from "./logging.ts";
|
|
7
6
|
export { Agent, CallInfo } from "./agent.ts";
|
package/src/socket/client.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
|
+
import type { IncomingMessage } from "node:http";
|
|
2
3
|
import { randomBytes } from "node:crypto";
|
|
3
4
|
import * as z from "zod";
|
|
4
5
|
import type { Client } from "../client.ts";
|
|
@@ -134,6 +135,12 @@ export class GuavaSocket<SendT, RecvT> {
|
|
|
134
135
|
const headers = await this._client.headers();
|
|
135
136
|
|
|
136
137
|
try {
|
|
138
|
+
this._logger.debug(
|
|
139
|
+
"Connecting to %s (attempt %d/%d)...",
|
|
140
|
+
this._connectionUrl,
|
|
141
|
+
attempt,
|
|
142
|
+
MAX_RECONNECT_ATTEMPTS,
|
|
143
|
+
);
|
|
137
144
|
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
|
138
145
|
const socket = new WebSocket(this._connectionUrl, { headers });
|
|
139
146
|
let settled = false;
|
|
@@ -147,6 +154,21 @@ export class GuavaSocket<SendT, RecvT> {
|
|
|
147
154
|
};
|
|
148
155
|
|
|
149
156
|
socket.once("error", (err) => settle(() => reject(err)));
|
|
157
|
+
socket.once("unexpected-response", (_req, res: IncomingMessage) => {
|
|
158
|
+
let body = "";
|
|
159
|
+
res.on("data", (chunk: Buffer) => {
|
|
160
|
+
body += chunk.toString();
|
|
161
|
+
});
|
|
162
|
+
res.on("end", () => {
|
|
163
|
+
settle(() =>
|
|
164
|
+
reject(
|
|
165
|
+
new Error(
|
|
166
|
+
`Unexpected server response: ${res.statusCode}. Body: ${body || "<empty>"}`,
|
|
167
|
+
),
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
150
172
|
socket.once("close", () =>
|
|
151
173
|
settle(() => reject(new Error("Connection closed before open-ack"))),
|
|
152
174
|
);
|