@guava-ai/guava-sdk 0.1.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/bin/example-runner.js +24 -0
- package/dist/examples/property-insurance.d.ts +1 -0
- package/dist/examples/property-insurance.js +41 -0
- package/dist/examples/property-insurance.js.map +1 -0
- package/dist/package.json +40 -0
- package/dist/src/action_item.d.ts +41 -0
- package/dist/src/action_item.js +29 -0
- package/dist/src/action_item.js.map +1 -0
- package/dist/src/commands.d.ts +181 -0
- package/dist/src/commands.js +69 -0
- package/dist/src/commands.js.map +1 -0
- package/dist/src/events.d.ts +170 -0
- package/dist/src/events.js +109 -0
- package/dist/src/events.js.map +1 -0
- package/dist/src/example_data.d.ts +1 -0
- package/dist/src/example_data.js +610 -0
- package/dist/src/example_data.js.map +1 -0
- package/dist/src/helpers/openai.d.ts +12 -0
- package/dist/src/helpers/openai.js +111 -0
- package/dist/src/helpers/openai.js.map +1 -0
- package/dist/src/index.d.ts +58 -0
- package/dist/src/index.js +400 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/logging.d.ts +9 -0
- package/dist/src/logging.js +26 -0
- package/dist/src/logging.js.map +1 -0
- package/examples/property-insurance.ts +49 -0
- package/package.json +40 -0
- package/src/action_item.ts +42 -0
- package/src/commands.ts +94 -0
- package/src/events.ts +142 -0
- package/src/example_data.ts +609 -0
- package/src/helpers/openai.ts +105 -0
- package/src/index.ts +463 -0
- package/src/logging.ts +38 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import OpenAI, { toFile } from "openai";
|
|
2
|
+
import { type Logger } from "../logging.ts";
|
|
3
|
+
|
|
4
|
+
// from beta.py
|
|
5
|
+
// TODO: Remove after beta
|
|
6
|
+
function beta_create_openai_client(logger: Logger) {
|
|
7
|
+
const baseUrl =
|
|
8
|
+
process.env.GUAVA_BASE_URL ?? "wss://guava-dev.gridspace.com/guava/";
|
|
9
|
+
// to get it working with OpenAI TS/JS client
|
|
10
|
+
const basedUrl = new URL("openai/v1/", baseUrl);
|
|
11
|
+
basedUrl.protocol = /^wss:\/\//.test(basedUrl.toString())
|
|
12
|
+
? "https:"
|
|
13
|
+
: "http:";
|
|
14
|
+
logger.info(`Creating beta OpenAI client`);
|
|
15
|
+
return new OpenAI({
|
|
16
|
+
baseURL: basedUrl.toString(),
|
|
17
|
+
apiKey: process.env.GUAVA_API_KEY,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class IntentRecognizer {}
|
|
22
|
+
|
|
23
|
+
export class DocumentQA {
|
|
24
|
+
private client: OpenAI;
|
|
25
|
+
private vector_store: Promise<OpenAI.VectorStore>;
|
|
26
|
+
private logger: Logger;
|
|
27
|
+
constructor(vector_store_name: string, document: string, logger: Logger, client?: OpenAI) {
|
|
28
|
+
this.client = client ?? beta_create_openai_client(logger);
|
|
29
|
+
this.vector_store = this.getOrCreateVectorStore(
|
|
30
|
+
vector_store_name,
|
|
31
|
+
document,
|
|
32
|
+
);
|
|
33
|
+
this.logger = logger;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async getOrCreateVectorStore(vector_store_name: string, document: string) {
|
|
37
|
+
const encoder = new TextEncoder();
|
|
38
|
+
const document_buffer = encoder.encode(document);
|
|
39
|
+
const document_hash_buffer = await crypto.subtle.digest(
|
|
40
|
+
"SHA-256",
|
|
41
|
+
document_buffer,
|
|
42
|
+
);
|
|
43
|
+
const u8view = new Uint8Array(document_hash_buffer);
|
|
44
|
+
const document_hash: string = Array.from(u8view)
|
|
45
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
46
|
+
.join("");
|
|
47
|
+
for await (const vs of this.client.vectorStores.list()) {
|
|
48
|
+
if (
|
|
49
|
+
vs.name == vector_store_name &&
|
|
50
|
+
vs.metadata &&
|
|
51
|
+
vs.metadata["document_hash"] == document_hash
|
|
52
|
+
) {
|
|
53
|
+
this.logger.info("Re-using existing vector store...");
|
|
54
|
+
return vs;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.logger.info("Creating vector store...");
|
|
59
|
+
|
|
60
|
+
const vector_store = await this.client.vectorStores.create({
|
|
61
|
+
name: vector_store_name,
|
|
62
|
+
expires_after: {
|
|
63
|
+
anchor: "last_active_at",
|
|
64
|
+
days: 7,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
this.logger.info("Uploading file...");
|
|
69
|
+
await this.client.vectorStores.files.uploadAndPoll(
|
|
70
|
+
vector_store.id,
|
|
71
|
+
await toFile(
|
|
72
|
+
new Blob([document], { type: "text/plain" }),
|
|
73
|
+
"document.txt",
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
this.logger.info("Updating vector store metadata...");
|
|
78
|
+
await this.client.vectorStores.update(vector_store.id, {
|
|
79
|
+
metadata: {
|
|
80
|
+
document_hash,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return vector_store;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async ask(question: string): Promise<string> {
|
|
88
|
+
const response = await this.client.responses.create({
|
|
89
|
+
model: "gpt-5-mini",
|
|
90
|
+
instructions:
|
|
91
|
+
"You are a virtual contact center agent. Your task is to answer questions using the provided supporting document. Just answer the question - do not offer any follow-ups.",
|
|
92
|
+
input: question,
|
|
93
|
+
tools: [
|
|
94
|
+
{
|
|
95
|
+
type: "file_search",
|
|
96
|
+
vector_store_ids: [(await this.vector_store).id],
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
reasoning: {
|
|
100
|
+
effort: "low",
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
return response.output_text;
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { type Logger, getConsoleLogger } from "./logging.ts";
|
|
3
|
+
import {
|
|
4
|
+
acceptInboundCallCommand,
|
|
5
|
+
type Command,
|
|
6
|
+
startOutboundCallCommand,
|
|
7
|
+
setPersona,
|
|
8
|
+
setTaskCommand,
|
|
9
|
+
answerQuestionCommand,
|
|
10
|
+
sendInstructionCommand,
|
|
11
|
+
readScriptCommand,
|
|
12
|
+
rejectInboundCallCommand,
|
|
13
|
+
transferCommand,
|
|
14
|
+
listenInboundCommand,
|
|
15
|
+
} from "./commands.ts";
|
|
16
|
+
import * as z from "zod";
|
|
17
|
+
import {
|
|
18
|
+
errorEvent,
|
|
19
|
+
type GuavaEvent,
|
|
20
|
+
sessionStartedEvent,
|
|
21
|
+
decodeEvent,
|
|
22
|
+
type CallerSpeechEvent,
|
|
23
|
+
type AgentSpeechEvent,
|
|
24
|
+
} from "./events.ts";
|
|
25
|
+
import type { ActionItem, Field, Say, Todo } from "./action_item.ts";
|
|
26
|
+
import pkgdata from "../package.json" with { type: "json" };
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
|
|
29
|
+
const DEFAULT_BASE_URL = "wss://guava-dev.gridspace.com/guava/";
|
|
30
|
+
const DEFAULT_LOG_LEVEL = "info";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @description convenience function for stringifying data according to a schema
|
|
34
|
+
*/
|
|
35
|
+
function stringifyZod<Schema extends z.ZodType>(
|
|
36
|
+
schema: Schema,
|
|
37
|
+
data: z.input<Schema>,
|
|
38
|
+
): string {
|
|
39
|
+
return JSON.stringify(schema.parse(data));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class CallController {
|
|
43
|
+
private _commandQueue: Command[] = [];
|
|
44
|
+
private _on_complete_current_task?: () => void;
|
|
45
|
+
// private _field_values: Record<string, any>;
|
|
46
|
+
private _current_task_id?: string;
|
|
47
|
+
private _logger: Logger;
|
|
48
|
+
// drain functions are expected to cleanup
|
|
49
|
+
// the part of the queue that is successfully sent from its
|
|
50
|
+
// input (mutating it) (i.e. _drain should use Array.splice)
|
|
51
|
+
private _drain?: (_: Command[]) => Promise<void>;
|
|
52
|
+
|
|
53
|
+
constructor(logger: Logger) {
|
|
54
|
+
// Set up the default logger.
|
|
55
|
+
this._logger = logger;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setDrain(newDrain: (_: Command[]) => Promise<void>) {
|
|
59
|
+
this._drain = newDrain;
|
|
60
|
+
this.flush();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
protected async acceptCall() {
|
|
64
|
+
await this.sendCommand(acceptInboundCallCommand, {
|
|
65
|
+
command_type: "accept-inbound",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
protected async readScript(script: string) {
|
|
70
|
+
await this.sendCommand(readScriptCommand, {
|
|
71
|
+
command_type: "read-script",
|
|
72
|
+
script: script,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
protected async rejectCall() {
|
|
77
|
+
await this.sendCommand(rejectInboundCallCommand, {
|
|
78
|
+
command_type: "reject-inbound",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
protected async addInfo(info: string) {
|
|
83
|
+
throw new Error("not implemeneted");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
protected async sendInstruction(instruction: string) {
|
|
87
|
+
await this.sendCommand(sendInstructionCommand, {
|
|
88
|
+
command_type: "send-instruction",
|
|
89
|
+
instruction: instruction,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
protected async setPersona(
|
|
94
|
+
organization_name?: string,
|
|
95
|
+
agent_name?: string,
|
|
96
|
+
agent_purpose?: string,
|
|
97
|
+
) {
|
|
98
|
+
await this.sendCommand(setPersona, {
|
|
99
|
+
command_type: "set-persona",
|
|
100
|
+
organization_name: organization_name,
|
|
101
|
+
agent_name: agent_name,
|
|
102
|
+
agent_purpose: agent_purpose,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
protected async setTask(
|
|
107
|
+
objective: string = "",
|
|
108
|
+
checklist?: (Field | Say | string)[],
|
|
109
|
+
on_complete: (...c) => void = () => {},
|
|
110
|
+
...args
|
|
111
|
+
) {
|
|
112
|
+
if (!objective && !checklist) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"At least one of args ['objective','checklist'] must be provided.",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
this._current_task_id = Math.random().toString(16).substring(2, 8);
|
|
118
|
+
checklist = checklist ?? [];
|
|
119
|
+
const action_items = checklist.map((item) =>
|
|
120
|
+
typeof item == "string"
|
|
121
|
+
? ({
|
|
122
|
+
item_type: "todo",
|
|
123
|
+
description: item,
|
|
124
|
+
} satisfies Todo)
|
|
125
|
+
: item,
|
|
126
|
+
) satisfies ActionItem[];
|
|
127
|
+
this._on_complete_current_task = on_complete.apply(this, args);
|
|
128
|
+
this.sendCommand(setTaskCommand, {
|
|
129
|
+
command_type: "set-task",
|
|
130
|
+
task_id: this._current_task_id,
|
|
131
|
+
objective: objective,
|
|
132
|
+
action_items: action_items,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
protected async hangup(final_instructions: string = "") {
|
|
137
|
+
let instructions: string;
|
|
138
|
+
if (final_instructions) {
|
|
139
|
+
instructions = `Start ending the conversation. Here are your final instructions: ${final_instructions} Once you've completed the final instructions, naturally end the conversation and hang up the call.`;
|
|
140
|
+
} else {
|
|
141
|
+
instructions = "Naturally end the conversation and hang up the call.";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
this.sendInstruction(instructions);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
protected transfer(to_number: string, transfer_message?: string) {
|
|
148
|
+
const message = transfer_message ?? "I'm transferring you now";
|
|
149
|
+
this.sendCommand(transferCommand, {
|
|
150
|
+
command_type: "transfer-call",
|
|
151
|
+
transfer_message: message,
|
|
152
|
+
to_number: to_number,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async sendCommand<C extends Command, Schema extends z.ZodType<C>>(
|
|
157
|
+
schema: Schema,
|
|
158
|
+
data: z.input<Schema>,
|
|
159
|
+
) {
|
|
160
|
+
const command = schema.parse(data);
|
|
161
|
+
this._commandQueue.push(command);
|
|
162
|
+
this._logger.debug(`Command queued: ${JSON.stringify(command)}`);
|
|
163
|
+
await this.flush();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async flush() {
|
|
167
|
+
await this._drain?.call(this, this._commandQueue);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async onEvent(event: GuavaEvent) {
|
|
171
|
+
if (event.event_type == "caller-speech") {
|
|
172
|
+
this.onCallerSpeech(event);
|
|
173
|
+
} else if (event.event_type == "agent-speech") {
|
|
174
|
+
this.onAgentSpeech(event);
|
|
175
|
+
} else if (event.event_type == "agent-question") {
|
|
176
|
+
try {
|
|
177
|
+
this._logger.info(`Received question from bot: ${event.question}`);
|
|
178
|
+
const answer = await this.onQuestion(event.question);
|
|
179
|
+
await this.sendCommand(answerQuestionCommand, {
|
|
180
|
+
command_type: "answer-question",
|
|
181
|
+
question_id: event.question_id,
|
|
182
|
+
answer: answer,
|
|
183
|
+
});
|
|
184
|
+
} catch (e) {
|
|
185
|
+
this._logger.error("Error occured while answering question.");
|
|
186
|
+
await this.sendCommand(answerQuestionCommand, {
|
|
187
|
+
command_type: "answer-question",
|
|
188
|
+
question_id: event.question_id,
|
|
189
|
+
answer: "An error occured and the question could not be answered.",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
} else if (event.event_type == "intent") {
|
|
193
|
+
this._logger.info(
|
|
194
|
+
`Received intent ${event.intent_id} from bot: ${event.intent_summary}`,
|
|
195
|
+
);
|
|
196
|
+
const intent_response = await this.onIntent(event.intent_summary);
|
|
197
|
+
if (intent_response) {
|
|
198
|
+
const response_str = `Responding to intent ${event.intent_id}: ${event.intent_summary}`;
|
|
199
|
+
this._logger.info(response_str);
|
|
200
|
+
this.sendInstruction(response_str);
|
|
201
|
+
}
|
|
202
|
+
} else if (event.event_type == "task-done") {
|
|
203
|
+
// ignore obsolete task_completed events
|
|
204
|
+
if (event.task_id == this._current_task_id) {
|
|
205
|
+
// assertion is implied
|
|
206
|
+
const on_complete = this._on_complete_current_task;
|
|
207
|
+
this._on_complete_current_task = undefined;
|
|
208
|
+
if (on_complete) {
|
|
209
|
+
on_complete();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} else if (event.event_type == "action-item-done") {
|
|
213
|
+
// self._field_values...
|
|
214
|
+
} else if (event.event_type == "inbound-call") {
|
|
215
|
+
this.onIncomingCall(event.caller_number);
|
|
216
|
+
} else if (
|
|
217
|
+
event.event_type == "outbound-call-connected" ||
|
|
218
|
+
event.event_type == "bot-session-ended"
|
|
219
|
+
) {
|
|
220
|
+
// no-op, don't warn
|
|
221
|
+
} else if (event.event_type == "error") {
|
|
222
|
+
this._logger.error(`The Guava agent reported an error: ${event.content}`);
|
|
223
|
+
} else {
|
|
224
|
+
this._logger.warn(`Unhandled event: ${JSON.stringify(event)}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// callbacks
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @requires super call
|
|
232
|
+
*/
|
|
233
|
+
async onIncomingCall(from_number?: string) {
|
|
234
|
+
await this.onCallStart();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async onCallStart(): Promise<void> {}
|
|
238
|
+
|
|
239
|
+
async onCallerSpeech(event: CallerSpeechEvent) {}
|
|
240
|
+
async onAgentSpeech(event: AgentSpeechEvent) {}
|
|
241
|
+
async onIntent(intent: string): Promise<string | null> {
|
|
242
|
+
return "Unfortunately I'm not able to help with that.";
|
|
243
|
+
}
|
|
244
|
+
async onQuestion(question: string): Promise<string> {
|
|
245
|
+
return "I don't have an answer to that question.";
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export type InboundConnection =
|
|
250
|
+
| { agent_number: string }
|
|
251
|
+
| { webrtc_code: string };
|
|
252
|
+
|
|
253
|
+
const ws_start = /^ws:\/\//;
|
|
254
|
+
const wss_start = /^wss:\/\//;
|
|
255
|
+
export class Client {
|
|
256
|
+
private _apiKey: string;
|
|
257
|
+
private _baseUrl: string;
|
|
258
|
+
private _logger: Logger;
|
|
259
|
+
private _ws?: WebSocket;
|
|
260
|
+
private _controller?: CallController;
|
|
261
|
+
private messageHandler?: (_: WebSocket.MessageEvent) => void;
|
|
262
|
+
|
|
263
|
+
constructor(apiKey?: string, baseUrl?: string, logger?: Logger) {
|
|
264
|
+
// Set up the default logger.
|
|
265
|
+
if (logger) {
|
|
266
|
+
this._logger = logger;
|
|
267
|
+
} else {
|
|
268
|
+
this._logger = getConsoleLogger(DEFAULT_LOG_LEVEL);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Resolve the API base URL.
|
|
272
|
+
if (baseUrl) {
|
|
273
|
+
this._baseUrl = baseUrl;
|
|
274
|
+
} else if (process.env.GUAVA_BASE_URL) {
|
|
275
|
+
this._baseUrl = process.env.GUAVA_BASE_URL;
|
|
276
|
+
} else {
|
|
277
|
+
this._baseUrl = DEFAULT_BASE_URL;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Resolve the API key.
|
|
281
|
+
if (apiKey) {
|
|
282
|
+
this._apiKey = apiKey;
|
|
283
|
+
} else if (process.env.GUAVA_API_KEY) {
|
|
284
|
+
this._apiKey = process.env.GUAVA_API_KEY;
|
|
285
|
+
} else {
|
|
286
|
+
throw new Error(
|
|
287
|
+
"Guava API key must be provided either as argument to client constructor, or in environment variable GUAVA_API_KEY.",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private getHttpBase() {
|
|
293
|
+
if (ws_start.test(this._baseUrl)) {
|
|
294
|
+
return `ws://${this._baseUrl.substring("ws://".length)}`;
|
|
295
|
+
} else if (wss_start.test(this._baseUrl)) {
|
|
296
|
+
return `wss://${this._baseUrl.substring("wss://".length)}`;
|
|
297
|
+
} else {
|
|
298
|
+
throw new Error(`Invalid base URL: ${this._baseUrl}}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private getWebsocketBase() {
|
|
303
|
+
return this._baseUrl;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private headers() {
|
|
307
|
+
return {
|
|
308
|
+
Authorization: `Bearer ${this._apiKey}`,
|
|
309
|
+
"x-guava-platform": os.platform(),
|
|
310
|
+
"x-guava-runtime": process.release.name,
|
|
311
|
+
"x-guava-runtime-version": process.version,
|
|
312
|
+
"x-guava-sdk": "typescript-sdk",
|
|
313
|
+
"x-guava-sdk-version": pkgdata.version,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
createOutbound(
|
|
318
|
+
fromNumber: string | undefined,
|
|
319
|
+
toNumber: string,
|
|
320
|
+
callControllerFactory?: (logger: Logger) => CallController,
|
|
321
|
+
) {
|
|
322
|
+
const url = new URL("create-outbound", this.getWebsocketBase()).toString();
|
|
323
|
+
const ws = new WebSocket(url, {
|
|
324
|
+
headers: this.headers(),
|
|
325
|
+
});
|
|
326
|
+
const callController = (callControllerFactory ?? ((_) => undefined))(this._logger);
|
|
327
|
+
|
|
328
|
+
ws.addEventListener("open", async (_ev) => {
|
|
329
|
+
ws.send(
|
|
330
|
+
stringifyZod(startOutboundCallCommand, {
|
|
331
|
+
command_type: "start-outbound",
|
|
332
|
+
to_number: toNumber,
|
|
333
|
+
from_number: fromNumber,
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
await callController?.onCallStart();
|
|
337
|
+
});
|
|
338
|
+
ws.addEventListener("close", (_ev) => {
|
|
339
|
+
// we are closing the socket, so don't trigger any other listeners
|
|
340
|
+
ws.removeAllListeners();
|
|
341
|
+
this._ws = undefined;
|
|
342
|
+
this._controller = undefined;
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
this._ws = ws;
|
|
346
|
+
this._controller = callController;
|
|
347
|
+
this.replaceHandler(this.uninitializedOutbound.bind(this));
|
|
348
|
+
|
|
349
|
+
// set the callController drain function to send all commands
|
|
350
|
+
// through the websocket
|
|
351
|
+
callController?.setDrain(async (commands) => {
|
|
352
|
+
for (const command of commands.splice(0)) {
|
|
353
|
+
this._logger.debug(`Sending command ${JSON.stringify(command)}`);
|
|
354
|
+
ws.send(JSON.stringify(command));
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
replaceHandler(newHandler?: (_: WebSocket.MessageEvent) => void) {
|
|
360
|
+
if (this.messageHandler) {
|
|
361
|
+
this._ws?.removeEventListener("message", this.messageHandler);
|
|
362
|
+
}
|
|
363
|
+
if (newHandler) {
|
|
364
|
+
this._ws?.addEventListener("message", newHandler);
|
|
365
|
+
}
|
|
366
|
+
this.messageHandler = newHandler;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// eventlistener handlers for server events
|
|
370
|
+
// (a state machine in functions)
|
|
371
|
+
private uninitializedOutbound(ev: WebSocket.MessageEvent) {
|
|
372
|
+
// for correctness (and type correctness)
|
|
373
|
+
if (!this._ws) {
|
|
374
|
+
throw new Error("[internal] Uninitialized WebSocket");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const session_started = z
|
|
378
|
+
.union([sessionStartedEvent, errorEvent])
|
|
379
|
+
.parse(JSON.parse(ev.data.toString("utf8")));
|
|
380
|
+
if (session_started.event_type == "error") {
|
|
381
|
+
throw new Error(`Outbound call failed: ${session_started.content}`);
|
|
382
|
+
} else {
|
|
383
|
+
this._logger.info(
|
|
384
|
+
`Started session with ID: ${session_started.session_id}`,
|
|
385
|
+
);
|
|
386
|
+
// move to next state
|
|
387
|
+
this.replaceHandler(this.initializedOutbound.bind(this));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// this._controller?.flush();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private async initializedOutbound(ev: WebSocket.MessageEvent) {
|
|
394
|
+
// for correctness (and type correctness)
|
|
395
|
+
if (!this._ws) {
|
|
396
|
+
throw new Error("[internal] Uninitialized WebSocket");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// handle the received event
|
|
400
|
+
const event = decodeEvent(ev.data);
|
|
401
|
+
if (event) {
|
|
402
|
+
if (this._controller) {
|
|
403
|
+
await this._controller.onEvent(event);
|
|
404
|
+
}
|
|
405
|
+
if (
|
|
406
|
+
event.event_type == "outbound-call-failed" ||
|
|
407
|
+
event.event_type == "bot-session-ended"
|
|
408
|
+
) {
|
|
409
|
+
// shutdown the websocket
|
|
410
|
+
this._ws.close();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// this._controller?.flush();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
setInboundHandler(
|
|
418
|
+
agent_number: string,
|
|
419
|
+
public_url: string,
|
|
420
|
+
inbound_token: string,
|
|
421
|
+
) {
|
|
422
|
+
// TODO
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// why use a typevar here and not for outbound?
|
|
426
|
+
listenInbound<U extends CallController>(
|
|
427
|
+
conn: InboundConnection,
|
|
428
|
+
controller_class: U,
|
|
429
|
+
) {
|
|
430
|
+
const call_controllers: Record<string, U> = {};
|
|
431
|
+
|
|
432
|
+
const url = new URL("listen-inbound", this.getWebsocketBase()).toString();
|
|
433
|
+
const ws = new WebSocket(url, {
|
|
434
|
+
headers: this.headers(),
|
|
435
|
+
});
|
|
436
|
+
let agent_number: string | undefined;
|
|
437
|
+
let webrtc_code: string | undefined;
|
|
438
|
+
if ("agent_number" in conn) {
|
|
439
|
+
agent_number = conn.agent_number;
|
|
440
|
+
} else {
|
|
441
|
+
webrtc_code = conn.webrtc_code;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
ws.send(
|
|
445
|
+
stringifyZod(listenInboundCommand, {
|
|
446
|
+
command_type: "listen-inbound",
|
|
447
|
+
agent_number: agent_number,
|
|
448
|
+
webrtc_code: webrtc_code,
|
|
449
|
+
}),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
this._logger.info(`Listening for calls to ${agent_number ?? webrtc_code}`);
|
|
453
|
+
|
|
454
|
+
if (webrtc_code) {
|
|
455
|
+
// converted to print, but should be logger?
|
|
456
|
+
const debugurl = new URL(
|
|
457
|
+
`debug-webrtc?webrtc_code=${webrtc_code}`,
|
|
458
|
+
this.getHttpBase(),
|
|
459
|
+
);
|
|
460
|
+
this._logger.debug(`WebRTC DebugURL: ${new URL("debug-webrtc?we")}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
package/src/logging.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
type LogLevel = "off" | "error" | "warn" | "info" | "debug";
|
|
2
|
+
|
|
3
|
+
const LOG_RANKS: Record<LogLevel, number> = {
|
|
4
|
+
off: 0,
|
|
5
|
+
error: 1,
|
|
6
|
+
warn: 2,
|
|
7
|
+
info: 3,
|
|
8
|
+
debug: 4,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export interface Logger {
|
|
12
|
+
debug(format: string, ...args: unknown[]);
|
|
13
|
+
info(format: string, ...args: unknown[]);
|
|
14
|
+
warn(format: string, ...args: unknown[]);
|
|
15
|
+
error(format: string, ...args: unknown[]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function shouldLog(messageLevel: LogLevel, loggerLevel: LogLevel) {
|
|
19
|
+
if (!LOG_RANKS.hasOwnProperty(messageLevel)) {
|
|
20
|
+
throw new Error(`Invalid log level: ${String(messageLevel)}`);
|
|
21
|
+
}
|
|
22
|
+
if (!LOG_RANKS.hasOwnProperty(loggerLevel)) {
|
|
23
|
+
throw new Error(`Invalid logger level: ${String(loggerLevel)}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return LOG_RANKS[messageLevel] <= LOG_RANKS[loggerLevel];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function noop(format: string, ...args: unknown[]) {}
|
|
30
|
+
|
|
31
|
+
export function getConsoleLogger(loggerLevel: LogLevel): Logger {
|
|
32
|
+
return {
|
|
33
|
+
debug: shouldLog("debug", loggerLevel) ? console.debug.bind(console) : noop,
|
|
34
|
+
info: shouldLog("info", loggerLevel) ? console.info.bind(console) : noop,
|
|
35
|
+
warn: shouldLog("warn", loggerLevel) ? console.warn.bind(console) : noop,
|
|
36
|
+
error: shouldLog("error", loggerLevel) ? console.error.bind(console) : noop,
|
|
37
|
+
};
|
|
38
|
+
}
|