@babelforce/babelconnect-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/CHANGELOG.md +18 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +110 -0
- package/dist/auth.d.ts +21 -0
- package/dist/auth.js +34 -0
- package/dist/client.d.ts +114 -0
- package/dist/client.js +322 -0
- package/dist/embed/index.d.ts +79 -0
- package/dist/embed/index.js +115 -0
- package/dist/gen/babelconnect/v1/babelconnect_connect.d.ts +132 -0
- package/dist/gen/babelconnect/v1/babelconnect_connect.js +161 -0
- package/dist/gen/babelconnect/v1/babelconnect_pb.d.ts +2362 -0
- package/dist/gen/babelconnect/v1/babelconnect_pb.js +3086 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +19 -0
- package/dist/media.d.ts +52 -0
- package/dist/media.js +97 -0
- package/dist/state-cache.d.ts +30 -0
- package/dist/state-cache.js +106 -0
- package/package.json +50 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { createClient } from "@connectrpc/connect";
|
|
2
|
+
import { createGrpcWebTransport } from "@connectrpc/connect-web";
|
|
3
|
+
import { Agent } from "./gen/babelconnect/v1/babelconnect_connect.js";
|
|
4
|
+
import { AddConferenceMember, AnswerCall, CallDirection, CallLifecycle, CallSource, Command, EndConference, Error as BcError, FlagRecording, Hangup, HistoryRequest, SmsThreadRequest, PhonebookRequest, HoldConferenceMember, Hold, KickConferenceMember, LeaveConference, MarkConversationRead, Mute, MuteConferenceMember, PlaceCall, Register, ResetLineStatus, SendDigits, SendSmsRequest, SetAgentNumber, SetConversationOpen, SetDisplayAs, SetPresence, SetWebrtc, StartConference, StartRecording, StopRecording, SetRecordingTags, SubscribeRequest, Transfer, WrapUpCancel, WrapUpExtend, } from "./gen/babelconnect/v1/babelconnect_pb.js";
|
|
5
|
+
import { browserMediaFactory, toRTCIceServers } from "./media.js";
|
|
6
|
+
import { StateCache } from "./state-cache.js";
|
|
7
|
+
/**
|
|
8
|
+
* The TypeScript "dumb renderer" client: opens the `Subscribe`/`Send` gRPC-web
|
|
9
|
+
* split, mirrors the server's `AgentView` in a {@link StateCache}, exposes typed
|
|
10
|
+
* intent senders, and drives a pluggable WebRTC {@link Media} leg. UI binds to
|
|
11
|
+
* {@link subscribe} and dispatches intents — no call/agent logic lives here.
|
|
12
|
+
*/
|
|
13
|
+
export class BabelconnectClient {
|
|
14
|
+
opts;
|
|
15
|
+
rpc;
|
|
16
|
+
cache = new StateCache();
|
|
17
|
+
media = new Map();
|
|
18
|
+
answering = new Set();
|
|
19
|
+
abort = new AbortController();
|
|
20
|
+
ready = false;
|
|
21
|
+
pending = [];
|
|
22
|
+
closed = false;
|
|
23
|
+
constructor(opts) {
|
|
24
|
+
this.opts = opts;
|
|
25
|
+
const transport = createGrpcWebTransport({
|
|
26
|
+
baseUrl: opts.serverUrl,
|
|
27
|
+
interceptors: [authInterceptor(opts.token)],
|
|
28
|
+
});
|
|
29
|
+
this.rpc = createClient(Agent, transport);
|
|
30
|
+
}
|
|
31
|
+
/** Open the session and start mirroring server state. */
|
|
32
|
+
static connect(opts) {
|
|
33
|
+
const c = new BabelconnectClient(opts);
|
|
34
|
+
void c.runSubscribe();
|
|
35
|
+
return c;
|
|
36
|
+
}
|
|
37
|
+
/** The current view (deep copy). */
|
|
38
|
+
get view() {
|
|
39
|
+
return this.cache.current;
|
|
40
|
+
}
|
|
41
|
+
/** Register a render callback (fired immediately and on every state update). Returns an unsubscribe fn. */
|
|
42
|
+
subscribe(fn) {
|
|
43
|
+
return this.cache.subscribe(fn);
|
|
44
|
+
}
|
|
45
|
+
/** The first active call, or undefined. */
|
|
46
|
+
activeCall() {
|
|
47
|
+
return this.cache.current.activeCalls[0];
|
|
48
|
+
}
|
|
49
|
+
// --- intents ---
|
|
50
|
+
register(capabilities = ["webrtc"]) {
|
|
51
|
+
this.send(new Command({ command: { case: "register", value: new Register({ capabilities }) } }));
|
|
52
|
+
}
|
|
53
|
+
placeCall(to, opts = {}) {
|
|
54
|
+
this.send(new Command({
|
|
55
|
+
command: {
|
|
56
|
+
case: "placeCall",
|
|
57
|
+
value: new PlaceCall({
|
|
58
|
+
to,
|
|
59
|
+
displayAsTo: opts.displayAsTo ?? "",
|
|
60
|
+
displayAsFrom: opts.displayAsFrom ?? "",
|
|
61
|
+
record: opts.record ?? false,
|
|
62
|
+
session: opts.session ?? {},
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
/** Manually answer a RINGING call by id (no-op under autoAnswer once already answered). */
|
|
68
|
+
async answerCall(callId) {
|
|
69
|
+
const call = this.cache.current.activeCalls.find((c) => c.id === callId);
|
|
70
|
+
if (call)
|
|
71
|
+
await this.doAnswer(call);
|
|
72
|
+
}
|
|
73
|
+
hangup(callId) {
|
|
74
|
+
this.send(new Command({ command: { case: "hangup", value: new Hangup({ callId }) } }));
|
|
75
|
+
}
|
|
76
|
+
mute(callId, on) {
|
|
77
|
+
this.send(new Command({ command: { case: "mute", value: new Mute({ callId, on }) } }));
|
|
78
|
+
}
|
|
79
|
+
hold(callId, on) {
|
|
80
|
+
this.send(new Command({ command: { case: "hold", value: new Hold({ callId, on }) } }));
|
|
81
|
+
}
|
|
82
|
+
sendDigits(callId, digits) {
|
|
83
|
+
this.send(new Command({ command: { case: "dtmf", value: new SendDigits({ callId, digits }) } }));
|
|
84
|
+
}
|
|
85
|
+
setDisplayAs(number) {
|
|
86
|
+
this.send(new Command({ command: { case: "setDisplayAs", value: new SetDisplayAs({ number }) } }));
|
|
87
|
+
}
|
|
88
|
+
/** Switch presence (the selector): "available" or a configured pause reason (see `AgentInfo.presenceOptions`). */
|
|
89
|
+
setPresence(name) {
|
|
90
|
+
this.send(new Command({ command: { case: "setPresence", value: new SetPresence({ name }) } }));
|
|
91
|
+
}
|
|
92
|
+
/** Transfer to exactly one of `to` (number), `agentId`, or `applicationId`; `warm` = attended. */
|
|
93
|
+
transfer(callId, to, opts = {}) {
|
|
94
|
+
this.send(new Command({
|
|
95
|
+
command: {
|
|
96
|
+
case: "transfer",
|
|
97
|
+
value: new Transfer({
|
|
98
|
+
callId,
|
|
99
|
+
to,
|
|
100
|
+
warm: opts.warm ?? false,
|
|
101
|
+
agentId: opts.agentId ?? "",
|
|
102
|
+
applicationId: opts.applicationId ?? "",
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
// --- Conferences ---
|
|
108
|
+
startConference(hold = false) {
|
|
109
|
+
this.send(new Command({ command: { case: "startConference", value: new StartConference({ hold }) } }));
|
|
110
|
+
}
|
|
111
|
+
addConferenceMember(opts) {
|
|
112
|
+
this.send(new Command({
|
|
113
|
+
command: {
|
|
114
|
+
case: "addConferenceMember",
|
|
115
|
+
value: new AddConferenceMember({ agentId: opts.agentId ?? "", number: opts.number ?? "" }),
|
|
116
|
+
},
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
kickConferenceMember(memberId) {
|
|
120
|
+
this.send(new Command({ command: { case: "kickConferenceMember", value: new KickConferenceMember({ memberId }) } }));
|
|
121
|
+
}
|
|
122
|
+
holdConferenceMember(memberId, on) {
|
|
123
|
+
this.send(new Command({ command: { case: "holdConferenceMember", value: new HoldConferenceMember({ memberId, on }) } }));
|
|
124
|
+
}
|
|
125
|
+
muteConferenceMember(memberId, on) {
|
|
126
|
+
this.send(new Command({ command: { case: "muteConferenceMember", value: new MuteConferenceMember({ memberId, on }) } }));
|
|
127
|
+
}
|
|
128
|
+
endConference() {
|
|
129
|
+
this.send(new Command({ command: { case: "endConference", value: new EndConference({}) } }));
|
|
130
|
+
}
|
|
131
|
+
leaveConference() {
|
|
132
|
+
this.send(new Command({ command: { case: "leaveConference", value: new LeaveConference({}) } }));
|
|
133
|
+
}
|
|
134
|
+
wrapUpExtend(seconds = 30) {
|
|
135
|
+
this.send(new Command({ command: { case: "wrapUpExtend", value: new WrapUpExtend({ seconds }) } }));
|
|
136
|
+
}
|
|
137
|
+
wrapUpCancel() {
|
|
138
|
+
this.send(new Command({ command: { case: "wrapUpCancel", value: new WrapUpCancel({}) } }));
|
|
139
|
+
}
|
|
140
|
+
resetLineStatus() {
|
|
141
|
+
this.send(new Command({ command: { case: "resetLineStatus", value: new ResetLineStatus({}) } }));
|
|
142
|
+
}
|
|
143
|
+
startRecording(callId) {
|
|
144
|
+
this.send(new Command({ command: { case: "startRecording", value: new StartRecording({ callId }) } }));
|
|
145
|
+
}
|
|
146
|
+
stopRecording(callId) {
|
|
147
|
+
this.send(new Command({ command: { case: "stopRecording", value: new StopRecording({ callId }) } }));
|
|
148
|
+
}
|
|
149
|
+
flagRecording(callId) {
|
|
150
|
+
this.send(new Command({ command: { case: "flagRecording", value: new FlagRecording({ callId }) } }));
|
|
151
|
+
}
|
|
152
|
+
setRecordingTags(callId, tags) {
|
|
153
|
+
this.send(new Command({ command: { case: "setRecordingTags", value: new SetRecordingTags({ callId, tags }) } }));
|
|
154
|
+
}
|
|
155
|
+
setWebrtc(on) {
|
|
156
|
+
this.send(new Command({ command: { case: "setWebrtc", value: new SetWebrtc({ on }) } }));
|
|
157
|
+
}
|
|
158
|
+
setAgentNumber(number) {
|
|
159
|
+
this.send(new Command({ command: { case: "setAgentNumber", value: new SetAgentNumber({ number }) } }));
|
|
160
|
+
}
|
|
161
|
+
/** Send an SMS. `from` may be empty (server picks the default); `session` carries CTI/embedding correlation. */
|
|
162
|
+
sendSms(to, text, opts = {}) {
|
|
163
|
+
this.send(new Command({
|
|
164
|
+
command: {
|
|
165
|
+
case: "sendSms",
|
|
166
|
+
value: new SendSmsRequest({ to, text, from: opts.from ?? "", session: opts.session ?? {} }),
|
|
167
|
+
},
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
/** Open (reopen) or close (resolve) an SMS conversation. */
|
|
171
|
+
setConversationOpen(conversationId, open) {
|
|
172
|
+
this.send(new Command({
|
|
173
|
+
command: { case: "setConversationOpen", value: new SetConversationOpen({ conversationId, open }) },
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
/** Clear the unread count on an SMS conversation (the agent opened its thread). */
|
|
177
|
+
markConversationRead(conversationId) {
|
|
178
|
+
this.send(new Command({
|
|
179
|
+
command: { case: "markConversationRead", value: new MarkConversationRead({ conversationId }) },
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
/** Fetch a page of the agent's call history (the History tab). */
|
|
183
|
+
async getHistory(max = 50, page = 1) {
|
|
184
|
+
const resp = await this.rpc.getHistory(new HistoryRequest({ max, page }));
|
|
185
|
+
return resp.calls;
|
|
186
|
+
}
|
|
187
|
+
/** Fetch the messages of one SMS conversation (the chat thread), oldest first. */
|
|
188
|
+
async getSmsThread(conversationId, max = 50, page = 1) {
|
|
189
|
+
const resp = await this.rpc.getSmsThread(new SmsThreadRequest({ conversationId, max, page }));
|
|
190
|
+
return resp.messages;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Re-pull the agent's merged contacts + recent numbers (the Contacts tab) — an
|
|
194
|
+
* on-demand refresh of the register-time `AgentInfo.phonebook` snapshot.
|
|
195
|
+
* `query` filters by label/number.
|
|
196
|
+
*/
|
|
197
|
+
async getPhonebook(max = 200, page = 1, query = "") {
|
|
198
|
+
const resp = await this.rpc.getPhonebook(new PhonebookRequest({ max, page, query }));
|
|
199
|
+
return resp.entries;
|
|
200
|
+
}
|
|
201
|
+
/** Tear down media legs and the connection. */
|
|
202
|
+
async close() {
|
|
203
|
+
this.closed = true;
|
|
204
|
+
this.abort.abort();
|
|
205
|
+
await this.closeAllMedia();
|
|
206
|
+
}
|
|
207
|
+
// --- internals ---
|
|
208
|
+
async runSubscribe() {
|
|
209
|
+
try {
|
|
210
|
+
for await (const u of this.rpc.subscribe(new SubscribeRequest({}), { signal: this.abort.signal })) {
|
|
211
|
+
this.onUpdate(u);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
if (!this.closed)
|
|
216
|
+
this.opts.onError?.(new BcError({ code: "disconnected", message: String(e) }));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
onUpdate(u) {
|
|
220
|
+
// First message = the snapshot ⇒ the session is registered server-side, so
|
|
221
|
+
// flush intents queued during connect() (register(), an early dial, …).
|
|
222
|
+
if (!this.ready) {
|
|
223
|
+
this.ready = true;
|
|
224
|
+
const queued = this.pending.splice(0);
|
|
225
|
+
for (const cmd of queued)
|
|
226
|
+
void this.rawSend(cmd);
|
|
227
|
+
}
|
|
228
|
+
if (u.update.case === "error") {
|
|
229
|
+
this.opts.onError?.(u.update.value);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (u.update.case === "patch" && u.update.value.change.case === "notification") {
|
|
233
|
+
this.opts.onNotification?.(u.update.value.change.value);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (this.cache.apply(u))
|
|
237
|
+
this.opts.onGap?.();
|
|
238
|
+
this.reconcile();
|
|
239
|
+
}
|
|
240
|
+
reconcile() {
|
|
241
|
+
const present = new Set();
|
|
242
|
+
const autoAnswer = this.opts.autoAnswer ?? true;
|
|
243
|
+
for (const call of this.cache.current.activeCalls) {
|
|
244
|
+
present.add(call.id);
|
|
245
|
+
// Auto-answer the agent's own dialed OUTBOUND leg. INBOUND calls — and
|
|
246
|
+
// OUTBOUND *callbacks* (a scheduled call to accept) — wait for answerCall().
|
|
247
|
+
if (autoAnswer &&
|
|
248
|
+
call.state === CallLifecycle.RINGING &&
|
|
249
|
+
call.webrtcOffer !== "" &&
|
|
250
|
+
call.direction === CallDirection.OUTBOUND &&
|
|
251
|
+
call.source !== CallSource.CALLBACK) {
|
|
252
|
+
void this.doAnswer(call);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (const [id, m] of this.media) {
|
|
256
|
+
if (!present.has(id)) {
|
|
257
|
+
void m.close();
|
|
258
|
+
this.media.delete(id);
|
|
259
|
+
this.answering.delete(id);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async doAnswer(call) {
|
|
264
|
+
if (this.answering.has(call.id) || this.media.has(call.id))
|
|
265
|
+
return;
|
|
266
|
+
const factory = this.opts.mediaFactory === undefined ? browserMediaFactory : this.opts.mediaFactory;
|
|
267
|
+
if (!factory) {
|
|
268
|
+
this.opts.onError?.(new BcError({ code: "no_media", message: "no media factory configured", callId: call.id }));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
this.answering.add(call.id);
|
|
272
|
+
try {
|
|
273
|
+
const media = factory(call.id);
|
|
274
|
+
// Thread the server-advertised STUN/TURN servers into the peer (off-host NAT
|
|
275
|
+
// traversal); when none are advertised (in-cluster/LAN) toRTCIceServers returns
|
|
276
|
+
// undefined so the Media's own fallback applies.
|
|
277
|
+
const answerSdp = await media.answer(call.webrtcOffer, toRTCIceServers(call.iceServers));
|
|
278
|
+
this.media.set(call.id, media);
|
|
279
|
+
this.send(new Command({ command: { case: "answer", value: new AnswerCall({ callId: call.id, sdp: answerSdp }) } }));
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
this.opts.onError?.(new BcError({ code: "media_answer_failed", message: String(e), callId: call.id }));
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
this.answering.delete(call.id);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
send(cmd) {
|
|
289
|
+
if (this.closed)
|
|
290
|
+
return;
|
|
291
|
+
// Hold intents until the Subscribe stream is live server-side (first snapshot),
|
|
292
|
+
// else the unary Send races stream registration and is rejected.
|
|
293
|
+
if (!this.ready) {
|
|
294
|
+
this.pending.push(cmd);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
void this.rawSend(cmd);
|
|
298
|
+
}
|
|
299
|
+
async rawSend(cmd) {
|
|
300
|
+
// Fire-and-forget: a command's RESULT (or rejection) arrives as state / an
|
|
301
|
+
// Error on the Subscribe stream. Only a transport failure surfaces here.
|
|
302
|
+
try {
|
|
303
|
+
await this.rpc.send(cmd);
|
|
304
|
+
}
|
|
305
|
+
catch (e) {
|
|
306
|
+
this.opts.onError?.(new BcError({ code: "send_failed", message: String(e) }));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async closeAllMedia() {
|
|
310
|
+
const all = [...this.media.values()];
|
|
311
|
+
this.media.clear();
|
|
312
|
+
this.answering.clear();
|
|
313
|
+
await Promise.all(all.map((m) => m.close()));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** An interceptor that attaches the bearer token to every gRPC-web call. */
|
|
317
|
+
function authInterceptor(token) {
|
|
318
|
+
return (next) => (req) => {
|
|
319
|
+
req.header.set("Authorization", `Bearer ${token}`);
|
|
320
|
+
return next(req);
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@babelforce/babelconnect-sdk/embed` — embed the babelconnect agent app (the
|
|
3
|
+
* server-served Flutter web app) into a host page via an `<iframe>` and a two-way
|
|
4
|
+
* `postMessage` bridge. See the Embedding guide at
|
|
5
|
+
* https://babelforce.github.io/babelconnect-sdk-docs/ for the full protocol.
|
|
6
|
+
* The embedded app talks only to babelconnect-server.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { BabelconnectEmbed } from "@babelforce/babelconnect-sdk/embed";
|
|
11
|
+
* const bc = BabelconnectEmbed.mount({
|
|
12
|
+
* container: document.getElementById("bc")!,
|
|
13
|
+
* serverUrl: "https://agent.example.com",
|
|
14
|
+
* token: await myLogin(),
|
|
15
|
+
* });
|
|
16
|
+
* bc.on("cti.call", (e) => console.log("call", e));
|
|
17
|
+
* bc.calls.dial("+49301234567");
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
/** Options for {@link BabelconnectEmbed.mount}. */
|
|
21
|
+
export interface EmbedOptions {
|
|
22
|
+
/** Element the iframe is appended to. */
|
|
23
|
+
container: HTMLElement;
|
|
24
|
+
/** babelconnect-server origin (the iframe `src` + the only origin messages are exchanged with). */
|
|
25
|
+
serverUrl: string;
|
|
26
|
+
/** Bearer token, handed to the app via `postMessage` after its `ready` event (never in the URL). */
|
|
27
|
+
token: string;
|
|
28
|
+
/** Optional initial session correlation (also settable later via {@link session}). */
|
|
29
|
+
session?: Record<string, unknown>;
|
|
30
|
+
/** Optional initial shared context (also settable later via {@link context}). */
|
|
31
|
+
context?: Record<string, unknown>;
|
|
32
|
+
/** Path within the app (default `/`). */
|
|
33
|
+
path?: string;
|
|
34
|
+
/** Optional className for the iframe. */
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
/** A handler for an app→host event's `data` payload. */
|
|
38
|
+
export type EmbedEventHandler = (data: unknown) => void;
|
|
39
|
+
/**
|
|
40
|
+
* A mounted embed. Use the verb groups ({@link calls}, {@link session},
|
|
41
|
+
* {@link context}, {@link app}) to drive the app, and {@link on} to receive
|
|
42
|
+
* app→host events (`agent.loaded`, `cti.call`, `cti.error`, …).
|
|
43
|
+
*/
|
|
44
|
+
export declare class BabelconnectEmbed {
|
|
45
|
+
private readonly opts;
|
|
46
|
+
private readonly iframe;
|
|
47
|
+
private readonly serverOrigin;
|
|
48
|
+
private readonly handlers;
|
|
49
|
+
private readonly onMessage;
|
|
50
|
+
private disposed;
|
|
51
|
+
private constructor();
|
|
52
|
+
/** Mount the embed: inject the iframe and start the bridge. */
|
|
53
|
+
static mount(opts: EmbedOptions): BabelconnectEmbed;
|
|
54
|
+
/** Click-to-dial: place a call (or `dial=false` to only pre-fill the dialer). */
|
|
55
|
+
readonly calls: {
|
|
56
|
+
dial: (number: string, dial?: boolean) => void;
|
|
57
|
+
};
|
|
58
|
+
/** Attach session correlation; a `number` pre-fills a new SMS. */
|
|
59
|
+
readonly session: {
|
|
60
|
+
set: (args: Record<string, unknown>) => void;
|
|
61
|
+
};
|
|
62
|
+
/** Merge into the persisted shared context carried onto subsequent calls/SMS. */
|
|
63
|
+
readonly context: {
|
|
64
|
+
set: (args: Record<string, unknown>) => void;
|
|
65
|
+
};
|
|
66
|
+
/** Route the agent to a tab: `phone` | `chat` | `history` | `outbound`. */
|
|
67
|
+
readonly app: {
|
|
68
|
+
setTab: (tab: string) => void;
|
|
69
|
+
};
|
|
70
|
+
/** Subscribe to an app→host event. Returns an unsubscribe fn. */
|
|
71
|
+
on(name: string, fn: EmbedEventHandler): () => void;
|
|
72
|
+
/** The underlying iframe (e.g. to adjust sizing). */
|
|
73
|
+
get element(): HTMLIFrameElement;
|
|
74
|
+
/** Remove the iframe and stop listening. */
|
|
75
|
+
dispose(): void;
|
|
76
|
+
private post;
|
|
77
|
+
private handleMessage;
|
|
78
|
+
private emit;
|
|
79
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@babelforce/babelconnect-sdk/embed` — embed the babelconnect agent app (the
|
|
3
|
+
* server-served Flutter web app) into a host page via an `<iframe>` and a two-way
|
|
4
|
+
* `postMessage` bridge. See the Embedding guide at
|
|
5
|
+
* https://babelforce.github.io/babelconnect-sdk-docs/ for the full protocol.
|
|
6
|
+
* The embedded app talks only to babelconnect-server.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { BabelconnectEmbed } from "@babelforce/babelconnect-sdk/embed";
|
|
11
|
+
* const bc = BabelconnectEmbed.mount({
|
|
12
|
+
* container: document.getElementById("bc")!,
|
|
13
|
+
* serverUrl: "https://agent.example.com",
|
|
14
|
+
* token: await myLogin(),
|
|
15
|
+
* });
|
|
16
|
+
* bc.on("cti.call", (e) => console.log("call", e));
|
|
17
|
+
* bc.calls.dial("+49301234567");
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* A mounted embed. Use the verb groups ({@link calls}, {@link session},
|
|
22
|
+
* {@link context}, {@link app}) to drive the app, and {@link on} to receive
|
|
23
|
+
* app→host events (`agent.loaded`, `cti.call`, `cti.error`, …).
|
|
24
|
+
*/
|
|
25
|
+
export class BabelconnectEmbed {
|
|
26
|
+
opts;
|
|
27
|
+
iframe;
|
|
28
|
+
serverOrigin;
|
|
29
|
+
handlers = new Map();
|
|
30
|
+
onMessage;
|
|
31
|
+
disposed = false;
|
|
32
|
+
constructor(opts) {
|
|
33
|
+
this.opts = opts;
|
|
34
|
+
this.serverOrigin = new URL(opts.serverUrl).origin;
|
|
35
|
+
const iframe = document.createElement("iframe");
|
|
36
|
+
iframe.src = opts.serverUrl.replace(/\/+$/, "") + (opts.path ?? "/");
|
|
37
|
+
iframe.allow = "microphone; autoplay";
|
|
38
|
+
iframe.style.border = "0";
|
|
39
|
+
iframe.style.width = "100%";
|
|
40
|
+
iframe.style.height = "100%";
|
|
41
|
+
if (opts.className)
|
|
42
|
+
iframe.className = opts.className;
|
|
43
|
+
this.iframe = iframe;
|
|
44
|
+
this.onMessage = (e) => this.handleMessage(e);
|
|
45
|
+
window.addEventListener("message", this.onMessage);
|
|
46
|
+
opts.container.appendChild(iframe);
|
|
47
|
+
}
|
|
48
|
+
/** Mount the embed: inject the iframe and start the bridge. */
|
|
49
|
+
static mount(opts) {
|
|
50
|
+
return new BabelconnectEmbed(opts);
|
|
51
|
+
}
|
|
52
|
+
/** Click-to-dial: place a call (or `dial=false` to only pre-fill the dialer). */
|
|
53
|
+
calls = {
|
|
54
|
+
dial: (number, dial = true) => this.post("calls", "calls.dial", { number, dial }),
|
|
55
|
+
};
|
|
56
|
+
/** Attach session correlation; a `number` pre-fills a new SMS. */
|
|
57
|
+
session = {
|
|
58
|
+
set: (args) => this.post("session", "session.set", args),
|
|
59
|
+
};
|
|
60
|
+
/** Merge into the persisted shared context carried onto subsequent calls/SMS. */
|
|
61
|
+
context = {
|
|
62
|
+
set: (args) => this.post("context", "context.set", args),
|
|
63
|
+
};
|
|
64
|
+
/** Route the agent to a tab: `phone` | `chat` | `history` | `outbound`. */
|
|
65
|
+
app = {
|
|
66
|
+
setTab: (tab) => this.post("app", "app.setTab", { tab }),
|
|
67
|
+
};
|
|
68
|
+
/** Subscribe to an app→host event. Returns an unsubscribe fn. */
|
|
69
|
+
on(name, fn) {
|
|
70
|
+
let set = this.handlers.get(name);
|
|
71
|
+
if (!set) {
|
|
72
|
+
set = new Set();
|
|
73
|
+
this.handlers.set(name, set);
|
|
74
|
+
}
|
|
75
|
+
set.add(fn);
|
|
76
|
+
return () => set.delete(fn);
|
|
77
|
+
}
|
|
78
|
+
/** The underlying iframe (e.g. to adjust sizing). */
|
|
79
|
+
get element() {
|
|
80
|
+
return this.iframe;
|
|
81
|
+
}
|
|
82
|
+
/** Remove the iframe and stop listening. */
|
|
83
|
+
dispose() {
|
|
84
|
+
if (this.disposed)
|
|
85
|
+
return;
|
|
86
|
+
this.disposed = true;
|
|
87
|
+
window.removeEventListener("message", this.onMessage);
|
|
88
|
+
this.iframe.remove();
|
|
89
|
+
this.handlers.clear();
|
|
90
|
+
}
|
|
91
|
+
post(module, name, args) {
|
|
92
|
+
this.iframe.contentWindow?.postMessage({ type: "connect", module, name, args }, this.serverOrigin);
|
|
93
|
+
}
|
|
94
|
+
handleMessage(e) {
|
|
95
|
+
if (e.origin !== this.serverOrigin)
|
|
96
|
+
return; // only trust the app's origin
|
|
97
|
+
const msg = e.data;
|
|
98
|
+
if (!msg || msg.type !== "bcConnect" || typeof msg.name !== "string")
|
|
99
|
+
return;
|
|
100
|
+
// Token handoff: on the app's `ready`, hand off the bearer token (+ optional
|
|
101
|
+
// initial session/context) over postMessage — never via the iframe URL.
|
|
102
|
+
if (msg.name === "ready") {
|
|
103
|
+
this.post("auth", "auth.set", {
|
|
104
|
+
token: this.opts.token,
|
|
105
|
+
session: this.opts.session,
|
|
106
|
+
context: this.opts.context,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
this.emit(msg.name, msg.data);
|
|
110
|
+
}
|
|
111
|
+
emit(name, data) {
|
|
112
|
+
for (const fn of this.handlers.get(name) ?? [])
|
|
113
|
+
fn(data);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Ack, AgentView, AuthenticateRequest, Command, GetStateRequest, HistoryRequest, HistoryResponse, Identity, PhonebookRequest, PhonebookResponse, SendSmsRequest, SmsConversation, SmsThreadRequest, SmsThreadResponse, StateUpdate, SubscribeRequest } from "./babelconnect_pb.js";
|
|
2
|
+
import { MethodKind } from "@bufbuild/protobuf";
|
|
3
|
+
/**
|
|
4
|
+
* @generated from service babelconnect.v1.Agent
|
|
5
|
+
*/
|
|
6
|
+
export declare const Agent: {
|
|
7
|
+
readonly typeName: "babelconnect.v1.Agent";
|
|
8
|
+
readonly methods: {
|
|
9
|
+
/**
|
|
10
|
+
* Authenticate validates the caller's bearer token and returns the resolved
|
|
11
|
+
* agent identity (id, account, presence).
|
|
12
|
+
*
|
|
13
|
+
* @generated from rpc babelconnect.v1.Agent.Authenticate
|
|
14
|
+
*/
|
|
15
|
+
readonly authenticate: {
|
|
16
|
+
readonly name: "Authenticate";
|
|
17
|
+
readonly I: typeof AuthenticateRequest;
|
|
18
|
+
readonly O: typeof Identity;
|
|
19
|
+
readonly kind: MethodKind.Unary;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Session opens the long-lived control stream. The client sends Command
|
|
23
|
+
* intents; the server streams StateUpdate (one snapshot on open, then
|
|
24
|
+
* entity-level patches). UI = f(AgentView).
|
|
25
|
+
*
|
|
26
|
+
* Native clients (Go/Dart desktop/mobile over HTTP/2) use this bidi stream.
|
|
27
|
+
*
|
|
28
|
+
* @generated from rpc babelconnect.v1.Agent.Session
|
|
29
|
+
*/
|
|
30
|
+
readonly session: {
|
|
31
|
+
readonly name: "Session";
|
|
32
|
+
readonly I: typeof Command;
|
|
33
|
+
readonly O: typeof StateUpdate;
|
|
34
|
+
readonly kind: MethodKind.BiDiStreaming;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Subscribe + Send are the browser-friendly split of Session: a browser speaks
|
|
38
|
+
* gRPC-web, which cannot client-stream, so the bidi Session is impossible there.
|
|
39
|
+
* Subscribe is the server→client half (the StateUpdate stream); Send is the
|
|
40
|
+
* client→server half (one Command per unary call). Together they are equivalent
|
|
41
|
+
* to Session for a single agent — the server keys both to the agent's one state
|
|
42
|
+
* store. Command rejections still arrive as an Error on the Subscribe stream
|
|
43
|
+
* (never on Send's reply), exactly as on Session.
|
|
44
|
+
*
|
|
45
|
+
* @generated from rpc babelconnect.v1.Agent.Subscribe
|
|
46
|
+
*/
|
|
47
|
+
readonly subscribe: {
|
|
48
|
+
readonly name: "Subscribe";
|
|
49
|
+
readonly I: typeof SubscribeRequest;
|
|
50
|
+
readonly O: typeof StateUpdate;
|
|
51
|
+
readonly kind: MethodKind.ServerStreaming;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* @generated from rpc babelconnect.v1.Agent.Send
|
|
55
|
+
*/
|
|
56
|
+
readonly send: {
|
|
57
|
+
readonly name: "Send";
|
|
58
|
+
readonly I: typeof Command;
|
|
59
|
+
readonly O: typeof Ack;
|
|
60
|
+
readonly kind: MethodKind.Unary;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* GetHistory returns the agent's past calls (the History tab). A unary query —
|
|
64
|
+
* history is on-demand reference data, not part of the live AgentView snapshot.
|
|
65
|
+
*
|
|
66
|
+
* @generated from rpc babelconnect.v1.Agent.GetHistory
|
|
67
|
+
*/
|
|
68
|
+
readonly getHistory: {
|
|
69
|
+
readonly name: "GetHistory";
|
|
70
|
+
readonly I: typeof HistoryRequest;
|
|
71
|
+
readonly O: typeof HistoryResponse;
|
|
72
|
+
readonly kind: MethodKind.Unary;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* GetSmsThread returns the messages of one SMS conversation (the chat thread).
|
|
76
|
+
* A unary query like GetHistory — the live AgentView carries only the
|
|
77
|
+
* SmsConversation summary; the full thread is fetched on demand. conversation_id
|
|
78
|
+
* is a query param (it can be a peer phone number when no conversation id is
|
|
79
|
+
* available), so this stays distinct from the POST /v1/agent/sms send.
|
|
80
|
+
*
|
|
81
|
+
* @generated from rpc babelconnect.v1.Agent.GetSmsThread
|
|
82
|
+
*/
|
|
83
|
+
readonly getSmsThread: {
|
|
84
|
+
readonly name: "GetSmsThread";
|
|
85
|
+
readonly I: typeof SmsThreadRequest;
|
|
86
|
+
readonly O: typeof SmsThreadResponse;
|
|
87
|
+
readonly kind: MethodKind.Unary;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* GetPhonebook returns the agent's dial-from contacts + recent numbers (the
|
|
91
|
+
* Contacts tab). A unary query like GetHistory: the live AgentView carries a
|
|
92
|
+
* phonebook snapshot frozen at register (AgentInfo.phonebook); this re-pulls the
|
|
93
|
+
* merged contacts+recents list on demand (refresh) so a long-running session can
|
|
94
|
+
* see new contacts. `query` filters by label/number.
|
|
95
|
+
*
|
|
96
|
+
* @generated from rpc babelconnect.v1.Agent.GetPhonebook
|
|
97
|
+
*/
|
|
98
|
+
readonly getPhonebook: {
|
|
99
|
+
readonly name: "GetPhonebook";
|
|
100
|
+
readonly I: typeof PhonebookRequest;
|
|
101
|
+
readonly O: typeof PhonebookResponse;
|
|
102
|
+
readonly kind: MethodKind.Unary;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* GetState returns the agent's current AgentView as a single unary call — the
|
|
106
|
+
* REST/web "get current state" (the Subscribe stream is the realtime twin). When
|
|
107
|
+
* no live session backs the caller, the server builds the snapshot on demand
|
|
108
|
+
* (agent + presence + wrap-up); live call state requires a stream.
|
|
109
|
+
*
|
|
110
|
+
* @generated from rpc babelconnect.v1.Agent.GetState
|
|
111
|
+
*/
|
|
112
|
+
readonly getState: {
|
|
113
|
+
readonly name: "GetState";
|
|
114
|
+
readonly I: typeof GetStateRequest;
|
|
115
|
+
readonly O: typeof AgentView;
|
|
116
|
+
readonly kind: MethodKind.Unary;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* SendSms sends an SMS and returns the (upserted) conversation summary. Exposed
|
|
120
|
+
* over REST as POST /v1/agent/sms and as the Command.send_sms intent on the
|
|
121
|
+
* stream — both send the same message.
|
|
122
|
+
*
|
|
123
|
+
* @generated from rpc babelconnect.v1.Agent.SendSms
|
|
124
|
+
*/
|
|
125
|
+
readonly sendSms: {
|
|
126
|
+
readonly name: "SendSms";
|
|
127
|
+
readonly I: typeof SendSmsRequest;
|
|
128
|
+
readonly O: typeof SmsConversation;
|
|
129
|
+
readonly kind: MethodKind.Unary;
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
};
|