@sphoro/bugcatch-web-sdk 0.13.1
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/LICENSE +21 -0
- package/README.md +717 -0
- package/dist/bugcatch.es.js +4393 -0
- package/dist/bugcatch.es.js.map +1 -0
- package/dist/bugcatch.umd.js +952 -0
- package/dist/bugcatch.umd.js.map +1 -0
- package/package.json +57 -0
- package/src/audio.js +191 -0
- package/src/capture.js +300 -0
- package/src/collectors.js +213 -0
- package/src/incoming-call.js +324 -0
- package/src/index.d.ts +684 -0
- package/src/index.js +1217 -0
- package/src/live-call.js +870 -0
- package/src/redact.js +177 -0
- package/src/session.js +189 -0
- package/src/updates.js +1062 -0
- package/src/voice.js +372 -0
- package/src/widget.js +2481 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRingBuffer, installConsoleCapture, installNetworkCapture,
|
|
3
|
+
installErrorCapture, collectEnvironment, collectEnvironmentSync,
|
|
4
|
+
} from './collectors.js';
|
|
5
|
+
import { mountWidget } from './widget.js';
|
|
6
|
+
import { uploadAttachment, normalizeMime, REPORT_MIMES } from './capture.js';
|
|
7
|
+
import {
|
|
8
|
+
watchUpdates, createUpdateNotifier, fetchMyReports, fetchReport, writeToken, readToken,
|
|
9
|
+
clearToken, sendMessage, callAvailable, callKindForProject, requestCall,
|
|
10
|
+
reportByCall, liveCallAvailable, requestLiveCall, giveUpLiveCall,
|
|
11
|
+
answerIncomingCall, declineIncomingCall,
|
|
12
|
+
readUnread, rememberUpdate, forgetUpdate, forgetReportUpdates, forgetAllUpdates,
|
|
13
|
+
fetchKnownIssues, matchKnownIssues, joinKnownIssue,
|
|
14
|
+
UNREAD_KEY,
|
|
15
|
+
} from './updates.js';
|
|
16
|
+
import {
|
|
17
|
+
loginState, startSignIn, confirmSignIn, endSignIn, readSession, clearSession,
|
|
18
|
+
} from './session.js';
|
|
19
|
+
import { startVoiceCall } from './voice.js';
|
|
20
|
+
import { joinLiveCall, liveCallSupported, openMicrophone } from './live-call.js';
|
|
21
|
+
import { showIncomingCall } from './incoming-call.js';
|
|
22
|
+
|
|
23
|
+
// Replaced at build time from package.json. The guard is for anyone importing
|
|
24
|
+
// src/ directly, where the define never runs.
|
|
25
|
+
const SDK_IDENTITY = {
|
|
26
|
+
name: typeof __SDK_NAME__ === 'string' ? __SDK_NAME__ : '@sphoro/bugcatch-web-sdk',
|
|
27
|
+
version: typeof __SDK_VERSION__ === 'string' ? __SDK_VERSION__ : '0.0.0-dev',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const DEFAULTS = {
|
|
31
|
+
apiBase: 'http://localhost:3000',
|
|
32
|
+
apiKey: null,
|
|
33
|
+
projectId: null,
|
|
34
|
+
consoleLimit: 100,
|
|
35
|
+
networkLimit: 50,
|
|
36
|
+
captureConsole: true,
|
|
37
|
+
captureNetwork: true,
|
|
38
|
+
captureErrors: true,
|
|
39
|
+
// Shows the floating "Report a bug" button. Turn off to drive it yourself.
|
|
40
|
+
widget: true,
|
|
41
|
+
widgetPosition: 'bottom-right',
|
|
42
|
+
// 'auto' follows the visitor's system setting; 'light' / 'dark' pin it.
|
|
43
|
+
theme: 'auto',
|
|
44
|
+
accent: '#4F46E5',
|
|
45
|
+
// Screenshot / screen recording / file attach. Capture needs getDisplayMedia,
|
|
46
|
+
// so it is offered only where the browser has it — file attach always works.
|
|
47
|
+
attachments: true,
|
|
48
|
+
maxAttachments: 5,
|
|
49
|
+
maxAttachmentBytes: 25 * 1024 * 1024,
|
|
50
|
+
maxRecordingMs: 120_000,
|
|
51
|
+
// Called before send; return false to cancel, or mutate the payload to scrub.
|
|
52
|
+
beforeSend: null,
|
|
53
|
+
user: null,
|
|
54
|
+
// Tell the reporter when a bug they filed changes status — live, in your
|
|
55
|
+
// app, where they already are. Off means no connection is opened at all.
|
|
56
|
+
updates: true,
|
|
57
|
+
// Show the built-in notification when an update arrives. It stays until the
|
|
58
|
+
// reporter marks it read — including across a reload — so an update is not
|
|
59
|
+
// lost to whoever happened to be looking at the tab. Turn it off and drive
|
|
60
|
+
// the notification yourself from onUpdate and unreadUpdates().
|
|
61
|
+
updateToast: true,
|
|
62
|
+
// Called with { id, bugId, title, status, at } whenever one of this browser's
|
|
63
|
+
// reports moves. Runs whether or not the built-in notification is on; `id` is
|
|
64
|
+
// what markUpdateRead() takes.
|
|
65
|
+
onUpdate: null,
|
|
66
|
+
// Offer a voice call when a report is picked up, if the workspace has
|
|
67
|
+
// sphoro.voice configured. Nothing is dialled and no permission is asked for
|
|
68
|
+
// until somebody presses the button — see startCall().
|
|
69
|
+
voice: true,
|
|
70
|
+
// Called as a call moves: 'connecting', 'listening', 'speaking', 'ended',
|
|
71
|
+
// 'failed'. The SDK shows nothing of its own beyond the offer, so an
|
|
72
|
+
// integration that wants a call UI builds it from this and onCallTranscript.
|
|
73
|
+
onCallState: null,
|
|
74
|
+
// Called with { role, text, final } for each line, as it is recognised.
|
|
75
|
+
onCallTranscript: null,
|
|
76
|
+
// Called with { bugId, callId, from, project, reference, ringsUntil,
|
|
77
|
+
// recorded } when somebody on the team rings this page about a report filed
|
|
78
|
+
// from it. The SDK has already drawn the card by then — this is for a host
|
|
79
|
+
// application that wants to duck its own audio or pause a video, not a hook
|
|
80
|
+
// that can suppress a call the person can see ringing. Set `voice: false` to
|
|
81
|
+
// switch the whole thing off.
|
|
82
|
+
onIncomingCall: null,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
class BugCatchClient {
|
|
86
|
+
constructor() {
|
|
87
|
+
this.config = { ...DEFAULTS };
|
|
88
|
+
this.started = false;
|
|
89
|
+
this.teardown = [];
|
|
90
|
+
this.consoleBuffer = createRingBuffer(DEFAULTS.consoleLimit);
|
|
91
|
+
this.networkBuffer = createRingBuffer(DEFAULTS.networkLimit);
|
|
92
|
+
this.stopWatching = null;
|
|
93
|
+
// The built-in notification stack, built on first use: an integration that
|
|
94
|
+
// renders its own never has one, and neither does a visitor who has nothing
|
|
95
|
+
// unread.
|
|
96
|
+
this.notifier = null;
|
|
97
|
+
// The call in progress, if any, and whether one is being connected. One at
|
|
98
|
+
// a time: a second would be two microphones on one page talking over each
|
|
99
|
+
// other.
|
|
100
|
+
this.call = null;
|
|
101
|
+
this.callStarting = false;
|
|
102
|
+
// The card on screen for a call the team is placing to this page, if any.
|
|
103
|
+
// Distinct from `call` above, which is a conversation in progress: this is
|
|
104
|
+
// a telephone that has not been picked up, and the two exist at different
|
|
105
|
+
// moments — a call cannot be ringing and connected at once.
|
|
106
|
+
this.ringing = null;
|
|
107
|
+
// event -> Set<handler>. Several parts of a host app can listen without
|
|
108
|
+
// having to share the single `onUpdate` config callback between them.
|
|
109
|
+
this.listeners = new Map();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
init(options = {}) {
|
|
113
|
+
if (this.started) {
|
|
114
|
+
console.warn('[BugCatch] init() called twice — ignoring the second call.');
|
|
115
|
+
return this;
|
|
116
|
+
}
|
|
117
|
+
this.config = { ...DEFAULTS, ...options };
|
|
118
|
+
|
|
119
|
+
if (!this.config.apiKey) throw new Error('[BugCatch] apiKey is required.');
|
|
120
|
+
if (!this.config.projectId) throw new Error('[BugCatch] projectId is required.');
|
|
121
|
+
|
|
122
|
+
this.consoleBuffer = createRingBuffer(this.config.consoleLimit);
|
|
123
|
+
this.networkBuffer = createRingBuffer(this.config.networkLimit);
|
|
124
|
+
|
|
125
|
+
if (this.config.captureConsole) this.teardown.push(installConsoleCapture(this.consoleBuffer));
|
|
126
|
+
if (this.config.captureNetwork) this.teardown.push(installNetworkCapture(this.networkBuffer));
|
|
127
|
+
if (this.config.captureErrors) this.teardown.push(installErrorCapture(this.consoleBuffer));
|
|
128
|
+
if (this.config.widget) this.teardown.push(mountWidget(this));
|
|
129
|
+
|
|
130
|
+
// Only if this browser has actually filed something. A visitor who has
|
|
131
|
+
// never reported has nothing to be told about, and opening a connection
|
|
132
|
+
// for them would be a request on every page load that can never deliver.
|
|
133
|
+
if (this.config.updates && readToken()) {
|
|
134
|
+
this.startWatching();
|
|
135
|
+
// Whatever was still unread when they last closed the tab. The stream
|
|
136
|
+
// replays nothing on connect, so without this an update that arrived
|
|
137
|
+
// while somebody was away would be gone by the time they came back.
|
|
138
|
+
//
|
|
139
|
+
// A tick late, so the count reaches a listener registered on the line
|
|
140
|
+
// after init() — which is where every integration registers one, and
|
|
141
|
+
// where a synchronous emit would fire into an empty room.
|
|
142
|
+
queueMicrotask(() => this.#drawUnread());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.started = true;
|
|
146
|
+
return this;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Open the update stream. Idempotent — init() and a first report both reach
|
|
151
|
+
* here, and a second connection would double every notification.
|
|
152
|
+
*/
|
|
153
|
+
startWatching() {
|
|
154
|
+
if (this.stopWatching) return this;
|
|
155
|
+
this.stopWatching = watchUpdates({
|
|
156
|
+
apiBase: this.config.apiBase,
|
|
157
|
+
onUpdate: (event) => {
|
|
158
|
+
// A telephone, not an update — taken first and returned on, so it
|
|
159
|
+
// neither queues behind a status line nor lands in the unread store. A
|
|
160
|
+
// call is worthless thirty seconds after it is sent, and an unread
|
|
161
|
+
// badge that still says "someone called" tomorrow morning is a badge
|
|
162
|
+
// nobody trusts.
|
|
163
|
+
if (event?.incomingCall) { this.#ring(event); return; }
|
|
164
|
+
if (event?.callWithdrawn) { this.#stopRinging(event.callWithdrawn.callId); return; }
|
|
165
|
+
|
|
166
|
+
// Held before it is shown, and held until it is read. The card on
|
|
167
|
+
// screen is a view of this store rather than the notification itself,
|
|
168
|
+
// which is what lets a reload, a second tab, or the widget marking it
|
|
169
|
+
// read all agree about what is still outstanding.
|
|
170
|
+
const update = rememberUpdate(event);
|
|
171
|
+
if (typeof this.config.onUpdate === 'function') this.config.onUpdate(update);
|
|
172
|
+
// Listeners registered with on('update') get the same frame. The
|
|
173
|
+
// widget uses this to refresh a report the reader has open, so a
|
|
174
|
+
// change lands on screen rather than waiting for them to reopen it —
|
|
175
|
+
// and to mark it read, which is why this runs before the redraw.
|
|
176
|
+
this.emit('update', update);
|
|
177
|
+
this.#drawUnread();
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
this.teardown.push(() => this.stopWatching?.());
|
|
181
|
+
// And a card left ringing when the SDK is torn down is a card that rings
|
|
182
|
+
// forever: the stream that would withdraw it has just been closed.
|
|
183
|
+
this.teardown.push(() => this.#stopRinging());
|
|
184
|
+
|
|
185
|
+
// Another tab of the same app reads one. They share the store, so they
|
|
186
|
+
// share what is outstanding — without this, a card marked read in one tab
|
|
187
|
+
// sits there in the other, over an app that already knows about it.
|
|
188
|
+
const onStorage = (event) => {
|
|
189
|
+
// A null key is localStorage.clear(), which takes the store with it.
|
|
190
|
+
if (event.key === UNREAD_KEY || event.key === null) this.#drawUnread();
|
|
191
|
+
};
|
|
192
|
+
window.addEventListener('storage', onStorage);
|
|
193
|
+
this.teardown.push(() => window.removeEventListener('storage', onStorage));
|
|
194
|
+
return this;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Subscribe to updates on this browser's reports. Returns an unsubscribe.
|
|
199
|
+
*
|
|
200
|
+
* const off = BugCatch.on('update', (u) => console.log(u.summary));
|
|
201
|
+
*
|
|
202
|
+
* Alongside the `onUpdate` config option rather than replacing it: that one
|
|
203
|
+
* is the single handler an integration sets at init, this one lets several
|
|
204
|
+
* parts of an app listen independently — a badge, a toast of your own, an
|
|
205
|
+
* open report view — without them having to agree on one callback.
|
|
206
|
+
*/
|
|
207
|
+
on(event, handler) {
|
|
208
|
+
if (typeof handler !== 'function') return () => {};
|
|
209
|
+
const set = this.listeners.get(event) || new Set();
|
|
210
|
+
set.add(handler);
|
|
211
|
+
this.listeners.set(event, set);
|
|
212
|
+
return () => this.off(event, handler);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
off(event, handler) {
|
|
216
|
+
this.listeners.get(event)?.delete(handler);
|
|
217
|
+
return this;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Notify listeners; one that throws must not stop the rest. */
|
|
221
|
+
emit(event, payload) {
|
|
222
|
+
for (const fn of this.listeners.get(event) || []) {
|
|
223
|
+
try {
|
|
224
|
+
fn(payload);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
// Someone else's handler, on someone else's page.
|
|
227
|
+
// eslint-disable-next-line no-console
|
|
228
|
+
console.error('[BugCatch] an update listener threw:', err);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Updates this browser has been told about and nobody has read yet, oldest
|
|
235
|
+
* first.
|
|
236
|
+
*
|
|
237
|
+
* BugCatch.unreadUpdates().length // a badge on your own bell
|
|
238
|
+
*
|
|
239
|
+
* Survives a reload: the stream replays nothing when it reconnects, so an
|
|
240
|
+
* update that arrived while the tab was closed would otherwise be gone.
|
|
241
|
+
* Each carries the `id` the mark-read calls take.
|
|
242
|
+
*/
|
|
243
|
+
unreadUpdates() {
|
|
244
|
+
return readUnread();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Mark one update read. It stops being shown, here and on the next load.
|
|
249
|
+
*
|
|
250
|
+
* BugCatch.on('update', (u) => myBell.add(u, () => BugCatch.markUpdateRead(u.id)));
|
|
251
|
+
*
|
|
252
|
+
* Read is something the reporter says, not something a timer decides — which
|
|
253
|
+
* is the whole reason these are held rather than shown for eight seconds and
|
|
254
|
+
* dropped. An integration rendering its own notifications is the one that
|
|
255
|
+
* knows when its reader has seen one, so it has to say.
|
|
256
|
+
*/
|
|
257
|
+
markUpdateRead(id) {
|
|
258
|
+
forgetUpdate(id);
|
|
259
|
+
this.#drawUnread();
|
|
260
|
+
return this;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Mark everything about one report read.
|
|
265
|
+
*
|
|
266
|
+
* Opening the report is reading its updates, so the widget calls this when
|
|
267
|
+
* the thread opens; an integration with its own report view should too.
|
|
268
|
+
*/
|
|
269
|
+
markReportRead(bugId) {
|
|
270
|
+
forgetReportUpdates(bugId);
|
|
271
|
+
this.#drawUnread();
|
|
272
|
+
return this;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Mark everything read. The "clear all" behind your own notification list. */
|
|
276
|
+
markUpdatesRead() {
|
|
277
|
+
forgetAllUpdates();
|
|
278
|
+
this.#drawUnread();
|
|
279
|
+
return this;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Redraw the built-in notifications, and tell anyone counting.
|
|
284
|
+
*
|
|
285
|
+
* The single path out of the unread store: every add and every mark-read ends
|
|
286
|
+
* here, so the cards on screen and a host app's own badge cannot disagree
|
|
287
|
+
* about what is outstanding.
|
|
288
|
+
*/
|
|
289
|
+
#drawUnread() {
|
|
290
|
+
const unread = readUnread();
|
|
291
|
+
// Emitted whether or not the built-in cards are on — this is the number a
|
|
292
|
+
// host app puts on its own bell.
|
|
293
|
+
this.emit('unread', { count: unread.length, updates: unread });
|
|
294
|
+
|
|
295
|
+
if (!this.config.updateToast) return;
|
|
296
|
+
if (!this.notifier) {
|
|
297
|
+
if (!unread.length) return;
|
|
298
|
+
this.notifier = createUpdateNotifier({
|
|
299
|
+
accent: this.config.accent,
|
|
300
|
+
position: this.config.widgetPosition,
|
|
301
|
+
onRead: (id) => this.markUpdateRead(id),
|
|
302
|
+
onReadAll: () => this.markUpdatesRead(),
|
|
303
|
+
// The call button appears only where the API said the offer stands and
|
|
304
|
+
// this page has calling switched on. Pressing it reads the update —
|
|
305
|
+
// acting on a notification is the clearest way there is of having seen
|
|
306
|
+
// it — and `startCall` does the rest.
|
|
307
|
+
onCall: this.config.voice
|
|
308
|
+
? (update) => {
|
|
309
|
+
this.markUpdateRead(update.id);
|
|
310
|
+
void this.startCall(update.bugId);
|
|
311
|
+
}
|
|
312
|
+
: null,
|
|
313
|
+
});
|
|
314
|
+
this.teardown.push(() => this.notifier?.destroy());
|
|
315
|
+
}
|
|
316
|
+
this.notifier.render(unread);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* One report in full, with its timeline — every step from filing to now.
|
|
321
|
+
*
|
|
322
|
+
* const report = await BugCatch.myReport(id);
|
|
323
|
+
* report.timeline // [{ kind: 'filed' | 'status' | 'severity' | ... , summary, at }]
|
|
324
|
+
*
|
|
325
|
+
* Resolves to null when this browser cannot prove it filed that report.
|
|
326
|
+
*/
|
|
327
|
+
myReport(bugId) {
|
|
328
|
+
return fetchReport({ apiBase: this.config.apiBase, bugId });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Write back on a report this browser filed.
|
|
333
|
+
*
|
|
334
|
+
* const key = await BugCatch.uploadAttachment(file, file.type);
|
|
335
|
+
* await BugCatch.replyToReport(id, {
|
|
336
|
+
* body: 'Still happening on Safari',
|
|
337
|
+
* attachments: [{ ...key, name: file.name, size: file.size }],
|
|
338
|
+
* });
|
|
339
|
+
*
|
|
340
|
+
* The widget's own conversation view calls this; it is public so an
|
|
341
|
+
* integration rendering its own can too. Resolves to the stored message, so
|
|
342
|
+
* the caller renders what the server kept rather than what it hoped it sent.
|
|
343
|
+
*/
|
|
344
|
+
replyToReport(bugId, { body, attachments } = {}) {
|
|
345
|
+
return sendMessage({ apiBase: this.config.apiBase, bugId, body, attachments });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Everything a report carries, however it was started.
|
|
350
|
+
*
|
|
351
|
+
* Shared by `report()` and by pressing Call, because the two file the same
|
|
352
|
+
* thing — the difference is only whether a person typed the title or the
|
|
353
|
+
* agent asks for it. Two copies of this would drift, and the half that
|
|
354
|
+
* drifted would be the one that stopped attaching the console.
|
|
355
|
+
*/
|
|
356
|
+
async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [] }) {
|
|
357
|
+
return {
|
|
358
|
+
projectId: this.config.projectId,
|
|
359
|
+
...(title ? { title } : {}),
|
|
360
|
+
description,
|
|
361
|
+
priority,
|
|
362
|
+
url: window.location.href,
|
|
363
|
+
reporterEmail: this.config.user?.email,
|
|
364
|
+
// Sent alongside the address so the dashboard can show a person rather
|
|
365
|
+
// than a mailbox, and so the resolution email can open with their name.
|
|
366
|
+
reporterName: this.config.user?.name,
|
|
367
|
+
metadata: {
|
|
368
|
+
...(await collectEnvironment()),
|
|
369
|
+
...metadata,
|
|
370
|
+
user: this.config.user || undefined,
|
|
371
|
+
sdk: SDK_IDENTITY,
|
|
372
|
+
},
|
|
373
|
+
logs: {
|
|
374
|
+
console: { entries: this.consoleBuffer.all() },
|
|
375
|
+
network: { entries: this.networkBuffer.all() },
|
|
376
|
+
},
|
|
377
|
+
// Omitted rather than sent empty: the field is optional server-side, and
|
|
378
|
+
// an empty array in every payload makes beforeSend hooks harder to read.
|
|
379
|
+
...(media.length ? { attachments: media } : {}),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* File a report because somebody pressed Call instead of filling the form in.
|
|
385
|
+
*
|
|
386
|
+
* Both kinds of call come through here — the triage agent and ringing the
|
|
387
|
+
* team — because filing is the half the two share, and it is the half that
|
|
388
|
+
* has to say who is filing. Held in one place after a session header went
|
|
389
|
+
* missing from this route while `report()` kept sending it: nothing looked
|
|
390
|
+
* wrong until a project switched sign-in on, and then a signed-in reporter
|
|
391
|
+
* pressing Call was told to sign in.
|
|
392
|
+
*/
|
|
393
|
+
async #fileByCall() {
|
|
394
|
+
const session = readSession(this.config.projectId);
|
|
395
|
+
|
|
396
|
+
let filed;
|
|
397
|
+
try {
|
|
398
|
+
filed = await reportByCall({
|
|
399
|
+
apiBase: this.config.apiBase,
|
|
400
|
+
apiKey: this.config.apiKey,
|
|
401
|
+
token: session?.token,
|
|
402
|
+
payload: await this.#payload({}),
|
|
403
|
+
});
|
|
404
|
+
} catch (err) {
|
|
405
|
+
// A session the server no longer recognises, dropped exactly as
|
|
406
|
+
// `report()` drops it: the widget re-reads state every time it opens, so
|
|
407
|
+
// forgetting it now is what makes it draw the sign-in step instead.
|
|
408
|
+
if (err.status === 401 && session) clearSession(this.config.projectId);
|
|
409
|
+
throw err;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// The same token `report()` keeps, and for the same reason: it is what lets
|
|
413
|
+
// this browser hear what happens to the report afterwards.
|
|
414
|
+
if (filed.watchToken) {
|
|
415
|
+
writeToken(filed.watchToken);
|
|
416
|
+
if (this.config.updates) this.startWatching();
|
|
417
|
+
}
|
|
418
|
+
return filed;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Whether this visitor can report something by talking about it instead.
|
|
423
|
+
*
|
|
424
|
+
* if (await BugCatch.canCallNow()) showYourOwnCallButton();
|
|
425
|
+
*
|
|
426
|
+
* Two conditions, and both have to hold before anything is drawn. The project
|
|
427
|
+
* has to have calling configured — false on most deployments — and this
|
|
428
|
+
* browser has to know who the visitor is, because a call with nobody to
|
|
429
|
+
* attribute it to has no watch token to follow it with and nobody to send the
|
|
430
|
+
* transcript to afterwards. That is the same limit every other follow-up
|
|
431
|
+
* feature in this SDK has: it all hangs off `identify()`.
|
|
432
|
+
*/
|
|
433
|
+
async canCallNow() {
|
|
434
|
+
return Boolean(await this.callKind());
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Which kind of call this visitor would get: 'agent', 'live', or null.
|
|
439
|
+
*
|
|
440
|
+
* const kind = await BugCatch.callKind();
|
|
441
|
+
* // 'agent' — a model answers and asks the questions the form did not
|
|
442
|
+
* // 'live' — the team's dashboards ring and a colleague picks up
|
|
443
|
+
*
|
|
444
|
+
* A deployment offers one or the other, never both: a sphoro.voice agent has
|
|
445
|
+
* exactly one mode. Worth asking rather than assuming, because the two are
|
|
446
|
+
* wholly different promises to make to somebody — and the wording next to the
|
|
447
|
+
* button has to match which one it is.
|
|
448
|
+
*
|
|
449
|
+
* The answer is remembered for the life of the page. It is a property of the
|
|
450
|
+
* deployment's configuration rather than of this visitor, and re-asking on
|
|
451
|
+
* every dialog open is a request per page view for something that changes
|
|
452
|
+
* about once a year.
|
|
453
|
+
*/
|
|
454
|
+
async callKind() {
|
|
455
|
+
if (!this.config.voice) return null;
|
|
456
|
+
if (!this.config.user?.email) return null;
|
|
457
|
+
if (this.kind !== undefined) return this.kind;
|
|
458
|
+
this.kind = await callKindForProject({
|
|
459
|
+
apiBase: this.config.apiBase,
|
|
460
|
+
apiKey: this.config.apiKey,
|
|
461
|
+
projectId: this.config.projectId,
|
|
462
|
+
});
|
|
463
|
+
return this.kind;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Whether a report can be talked through right now.
|
|
468
|
+
*
|
|
469
|
+
* if (await BugCatch.canCall(id)) showYourOwnButton();
|
|
470
|
+
*
|
|
471
|
+
* False on every deployment without sphoro.voice configured, on every
|
|
472
|
+
* workspace that has not switched calling on, and on a report that has had
|
|
473
|
+
* its allowance of calls — so an integration rendering its own affordance can
|
|
474
|
+
* ask rather than guess.
|
|
475
|
+
*/
|
|
476
|
+
canCall(bugId) {
|
|
477
|
+
if (!this.config.voice) return Promise.resolve(false);
|
|
478
|
+
return callAvailable({ apiBase: this.config.apiBase, bugId });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Talk to the triage agent.
|
|
483
|
+
*
|
|
484
|
+
* await BugCatch.startCall(id); // about a report already filed
|
|
485
|
+
* await BugCatch.startCall(); // instead of filling the form in
|
|
486
|
+
*
|
|
487
|
+
* With a report id, it talks about that report. With none, it files one first
|
|
488
|
+
* — carrying the console, network and environment this SDK is already holding
|
|
489
|
+
* — and then calls about that. The report is written before the call starts
|
|
490
|
+
* either way, so a call that drops still leaves the context behind; the
|
|
491
|
+
* handle carries `bugId` so the caller can say what it was filed as.
|
|
492
|
+
*
|
|
493
|
+
* Asks for the microphone, opens the call, and resolves once it is connected.
|
|
494
|
+
* What is said reaches the team as a message in the report's thread when the
|
|
495
|
+
* call ends — nobody from the team is on the line, and the agent says so.
|
|
496
|
+
*
|
|
497
|
+
* Rejects if the microphone is refused, if calling is not available, or if
|
|
498
|
+
* one is already running. There is deliberately no way to start a second: two
|
|
499
|
+
* microphones on one page talk over each other, and the second call would be
|
|
500
|
+
* transcribing the first. A rejection carrying `.filed` means the report was
|
|
501
|
+
* written and only the call failed.
|
|
502
|
+
*/
|
|
503
|
+
async startCall(bugId) {
|
|
504
|
+
if (!this.config.voice) throw new Error('[BugCatch] calling is switched off.');
|
|
505
|
+
// Two checks, because there are two ways to already be on a call: one that
|
|
506
|
+
// has connected, and one still asking for the microphone. A double-click
|
|
507
|
+
// races past a check on `this.call` alone — the second press gets through
|
|
508
|
+
// while the first is still awaiting, and the page ends up with two sockets
|
|
509
|
+
// and two microphones transcribing each other.
|
|
510
|
+
if (this.call || this.callStarting) {
|
|
511
|
+
throw new Error('[BugCatch] a call is already running.');
|
|
512
|
+
}
|
|
513
|
+
this.callStarting = true;
|
|
514
|
+
|
|
515
|
+
try {
|
|
516
|
+
return await this.#connectCall(bugId);
|
|
517
|
+
} finally {
|
|
518
|
+
this.callStarting = false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** The body of startCall, so the in-flight flag is cleared on every path. */
|
|
523
|
+
async #connectCall(bugId) {
|
|
524
|
+
// Which kind of call this deployment actually offers, asked before anything
|
|
525
|
+
// is filed. `startCall` is the one entry point most integrations use — the
|
|
526
|
+
// widget's own button included — so it has to land on whichever kind the
|
|
527
|
+
// agent is, rather than on the one this method was originally written for.
|
|
528
|
+
//
|
|
529
|
+
// Without this the agent path ran against a person-to-person agent: the call
|
|
530
|
+
// came back *ringing*, the API refused it rather than connect a reporter to a
|
|
531
|
+
// rendezvous over the µ-law agent protocol, and all the reporter saw was a
|
|
532
|
+
// report they had not meant to file.
|
|
533
|
+
if (await this.callKind() === 'live') return this.#ringTheTeam(bugId);
|
|
534
|
+
|
|
535
|
+
let realtimeUrl;
|
|
536
|
+
let filed = null;
|
|
537
|
+
|
|
538
|
+
if (bugId) {
|
|
539
|
+
({ realtime_url: realtimeUrl } = await requestCall({
|
|
540
|
+
apiBase: this.config.apiBase, bugId,
|
|
541
|
+
}));
|
|
542
|
+
} else {
|
|
543
|
+
// No report yet: this is somebody who pressed Call instead of filling the
|
|
544
|
+
// form in. One round trip files what the page was doing and starts the
|
|
545
|
+
// call about it, so a call that never connects still leaves the context
|
|
546
|
+
// behind.
|
|
547
|
+
filed = await this.#fileByCall();
|
|
548
|
+
bugId = filed.id;
|
|
549
|
+
realtimeUrl = filed.realtime_url;
|
|
550
|
+
if (!realtimeUrl) {
|
|
551
|
+
// Filed, but no call. Reported as such rather than thrown: the report
|
|
552
|
+
// exists and the caller has to be able to say so.
|
|
553
|
+
const err = new Error('[BugCatch] the report was filed, but no call could be started.');
|
|
554
|
+
err.filed = filed;
|
|
555
|
+
throw err;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const handle = await startVoiceCall({
|
|
560
|
+
realtimeUrl,
|
|
561
|
+
onState: (state, detail) => {
|
|
562
|
+
if (state === 'ended' || state === 'failed') this.call = null;
|
|
563
|
+
if (typeof this.config.onCallState === 'function') {
|
|
564
|
+
this.config.onCallState(state, { bugId, ...detail });
|
|
565
|
+
}
|
|
566
|
+
this.emit('call', { bugId, state, ...detail });
|
|
567
|
+
},
|
|
568
|
+
onTranscript: (line) => {
|
|
569
|
+
if (typeof this.config.onCallTranscript === 'function') {
|
|
570
|
+
this.config.onCallTranscript({ bugId, ...line });
|
|
571
|
+
}
|
|
572
|
+
this.emit('call-transcript', { bugId, ...line });
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// Set after the await, not before: a call that never connected is not one
|
|
577
|
+
// this page is on, and leaving the slot taken would block the retry.
|
|
578
|
+
this.call = handle;
|
|
579
|
+
// `bugId` on the handle so a caller that did not name one — the Call
|
|
580
|
+
// button — can tell the reporter what their report is called.
|
|
581
|
+
return Object.assign(handle, { bugId, filed });
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Talk to a person on the team.
|
|
586
|
+
*
|
|
587
|
+
* await BugCatch.startLiveCall(id);
|
|
588
|
+
*
|
|
589
|
+
* The other kind of call. `startCall` reaches the triage agent, which answers
|
|
590
|
+
* instantly and asks the three questions the form did not; this rings the
|
|
591
|
+
* team, and whoever is free picks it up. What comes back is a conversation
|
|
592
|
+
* with a colleague — the audio travels directly between the two browsers over
|
|
593
|
+
* WebRTC and never reaches bugcatch or sphoro.voice, so it is not recorded and
|
|
594
|
+
* not transcribed. The thread gets a note saying it happened and who took it.
|
|
595
|
+
*
|
|
596
|
+
* Resolves once the call is *ringing*, not once somebody answers — the
|
|
597
|
+
* handle's `onState` is how the caller follows what happens next:
|
|
598
|
+
*
|
|
599
|
+
* 'ringing' the team's dashboards are lighting up
|
|
600
|
+
* 'connecting' somebody answered; the two browsers are finding each other
|
|
601
|
+
* 'connected' they can hear each other
|
|
602
|
+
* 'missed' nobody picked up before the ringing stopped
|
|
603
|
+
* 'ended' it is over
|
|
604
|
+
*
|
|
605
|
+
* A report id is required, unlike `startCall`. Ringing a room about a report
|
|
606
|
+
* nobody can read would ask somebody to answer a call they know nothing
|
|
607
|
+
* about — and the agent call's trick of filing first and taking a title from
|
|
608
|
+
* the transcript is not available here, because there is no transcript.
|
|
609
|
+
*
|
|
610
|
+
* Rejects if the microphone is refused, if live calling is not available on
|
|
611
|
+
* this report, or if a call of either kind is already running: this page has
|
|
612
|
+
* one microphone, and two calls on it would talk over each other.
|
|
613
|
+
*/
|
|
614
|
+
async startLiveCall(bugId) {
|
|
615
|
+
if (!this.config.voice) throw new Error('[BugCatch] calling is switched off.');
|
|
616
|
+
if (!bugId) throw new Error('[BugCatch] startLiveCall needs the id of a filed report.');
|
|
617
|
+
if (!liveCallSupported()) {
|
|
618
|
+
throw new Error('[BugCatch] this browser cannot make live calls.');
|
|
619
|
+
}
|
|
620
|
+
// The same two checks `startCall` makes, against the same slot, because the
|
|
621
|
+
// two kinds of call compete for the same microphone — a live call started
|
|
622
|
+
// while the agent is mid-sentence would be two conversations in one room.
|
|
623
|
+
if (this.call || this.callStarting) {
|
|
624
|
+
throw new Error('[BugCatch] a call is already running.');
|
|
625
|
+
}
|
|
626
|
+
this.callStarting = true;
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
return await this.#ringTheTeam(bugId);
|
|
630
|
+
} finally {
|
|
631
|
+
this.callStarting = false;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** The body of startLiveCall, so the in-flight flag clears on every path. */
|
|
636
|
+
async #ringTheTeam(bugId) {
|
|
637
|
+
// Before the call is placed, not after. A permission prompt takes as long
|
|
638
|
+
// as the person takes to read it, and asking afterwards would have a room
|
|
639
|
+
// full of dashboards ringing about a call its own caller may yet refuse to
|
|
640
|
+
// be on. That ordering is why the report below is filed *after* the
|
|
641
|
+
// microphone too, even though filing it first is what the agent path does:
|
|
642
|
+
// on this kind of call, filing is what makes the phones ring.
|
|
643
|
+
if (!liveCallSupported()) {
|
|
644
|
+
throw new Error('[BugCatch] this browser cannot make live calls.');
|
|
645
|
+
}
|
|
646
|
+
const microphone = await openMicrophone();
|
|
647
|
+
|
|
648
|
+
let started;
|
|
649
|
+
let filed = null;
|
|
650
|
+
try {
|
|
651
|
+
if (bugId) {
|
|
652
|
+
started = await requestLiveCall({ apiBase: this.config.apiBase, bugId });
|
|
653
|
+
} else {
|
|
654
|
+
// Nobody filled the form in. One round trip writes what the page was
|
|
655
|
+
// doing and rings the team about it — the same trade the agent path
|
|
656
|
+
// makes, so a call nobody answers still leaves the context behind.
|
|
657
|
+
filed = await this.#fileByCall();
|
|
658
|
+
bugId = filed.id;
|
|
659
|
+
started = filed.live_call;
|
|
660
|
+
if (!started?.realtime_url) {
|
|
661
|
+
// Filed, but nobody rang. Reported as such rather than thrown, so the
|
|
662
|
+
// caller can say what the report is called instead of losing it.
|
|
663
|
+
const err = new Error('[BugCatch] the report was filed, but the team could not be rung.');
|
|
664
|
+
err.filed = filed;
|
|
665
|
+
throw err;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
} catch (err) {
|
|
669
|
+
for (const track of microphone.getTracks()) track.stop();
|
|
670
|
+
throw err;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const handle = await joinLiveCall({
|
|
674
|
+
realtimeUrl: started.realtime_url,
|
|
675
|
+
stream: microphone,
|
|
676
|
+
ringsUntil: started.rings_until,
|
|
677
|
+
onState: (state, detail) => {
|
|
678
|
+
if (state === 'ended' || state === 'failed' || state === 'missed') this.call = null;
|
|
679
|
+
// Nobody picked up, as decided by the one client that was listening to
|
|
680
|
+
// it. Told to the API so the thread says so; the reporter has already
|
|
681
|
+
// been shown it, and nothing here waits for the answer.
|
|
682
|
+
if (state === 'missed') {
|
|
683
|
+
giveUpLiveCall({ apiBase: this.config.apiBase, bugId, callId: started.call_id });
|
|
684
|
+
}
|
|
685
|
+
if (typeof this.config.onCallState === 'function') {
|
|
686
|
+
this.config.onCallState(state, { bugId, live: true, ...detail });
|
|
687
|
+
}
|
|
688
|
+
this.emit('call', { bugId, live: true, state, ...detail });
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
// Set after the await, exactly as the agent path does: a call that never
|
|
693
|
+
// connected is not one this page is on, and leaving the slot taken would
|
|
694
|
+
// block the retry.
|
|
695
|
+
this.call = handle;
|
|
696
|
+
return Object.assign(handle, { bugId, callId: started.call_id, live: true, filed });
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Whether this report can be talked through with a person.
|
|
701
|
+
*
|
|
702
|
+
* For a host application drawing its own button. The widget asks this itself.
|
|
703
|
+
*/
|
|
704
|
+
canCallTeam(bugId) {
|
|
705
|
+
if (!this.config.voice) return Promise.resolve(false);
|
|
706
|
+
return liveCallAvailable({ apiBase: this.config.apiBase, bugId });
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// --- being called ------------------------------------------------------------
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Somebody on the team is ringing this page.
|
|
713
|
+
*
|
|
714
|
+
* The only thing in this SDK that starts without the person on the page
|
|
715
|
+
* asking for it, which is why what it does is so much narrower than what the
|
|
716
|
+
* outgoing calls do: it draws a card and makes a soft noise. No microphone is
|
|
717
|
+
* opened and nothing is connected until Answer is pressed, and if nothing is
|
|
718
|
+
* pressed the card takes itself away when the ringing stops.
|
|
719
|
+
*
|
|
720
|
+
* Silently ignored when calling is switched off in this integration's config
|
|
721
|
+
* or the browser cannot hold a peer connection. A host application that has
|
|
722
|
+
* said `voice: false` has said its customers are not to be rung, and a card
|
|
723
|
+
* offering a call this page could not join would be worse than none.
|
|
724
|
+
*/
|
|
725
|
+
#ring(event) {
|
|
726
|
+
const call = event.incomingCall;
|
|
727
|
+
if (!this.config.voice || !liveCallSupported()) return;
|
|
728
|
+
// Already ringing about this one — a reconnect replaying the frame, or a
|
|
729
|
+
// second delivery. One card either way.
|
|
730
|
+
if (this.ringing?.callId === call.callId) return;
|
|
731
|
+
// One microphone on this page, so one call. Somebody already talking is not
|
|
732
|
+
// interrupted by a card offering them a second conversation.
|
|
733
|
+
if (this.call || this.callStarting) return;
|
|
734
|
+
|
|
735
|
+
this.#stopRinging();
|
|
736
|
+
|
|
737
|
+
const dismiss = showIncomingCall({
|
|
738
|
+
call,
|
|
739
|
+
accent: this.config.accent,
|
|
740
|
+
position: this.config.widgetPosition,
|
|
741
|
+
onAnswer: () => { void this.#answer(event.bugId, call); },
|
|
742
|
+
onDecline: () => {
|
|
743
|
+
this.ringing = null;
|
|
744
|
+
declineIncomingCall({
|
|
745
|
+
apiBase: this.config.apiBase, bugId: event.bugId, callId: call.callId,
|
|
746
|
+
});
|
|
747
|
+
this.emit('incoming-call', { bugId: event.bugId, callId: call.callId, state: 'declined' });
|
|
748
|
+
},
|
|
749
|
+
// Nothing is reported here. The member who placed it is watching it ring
|
|
750
|
+
// on their own screen and their browser tells the API when it stops —
|
|
751
|
+
// the same rule as the other direction, where whoever placed the call is
|
|
752
|
+
// the one that reports nobody answered.
|
|
753
|
+
onExpire: () => {
|
|
754
|
+
this.ringing = null;
|
|
755
|
+
this.emit('incoming-call', { bugId: event.bugId, callId: call.callId, state: 'missed' });
|
|
756
|
+
},
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
this.ringing = { callId: call.callId, dismiss };
|
|
760
|
+
if (typeof this.config.onIncomingCall === 'function') {
|
|
761
|
+
// After the card, not instead of it. This is for a host application that
|
|
762
|
+
// wants to know — to duck its own audio, to pause a video — and not a
|
|
763
|
+
// hook that can suppress a call the person can see ringing.
|
|
764
|
+
try { this.config.onIncomingCall({ bugId: event.bugId, ...call }); } catch { /* theirs */ }
|
|
765
|
+
}
|
|
766
|
+
this.emit('incoming-call', { bugId: event.bugId, callId: call.callId, state: 'ringing' });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** Take the card away, whatever it is showing. */
|
|
770
|
+
#stopRinging(callId) {
|
|
771
|
+
if (!this.ringing) return;
|
|
772
|
+
if (callId && this.ringing.callId !== callId) return;
|
|
773
|
+
try { this.ringing.dismiss(); } catch { /* already gone */ }
|
|
774
|
+
this.ringing = null;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Answer it: microphone first, then the claim, then the socket.
|
|
779
|
+
*
|
|
780
|
+
* That order is the same one every other call in this file uses and it
|
|
781
|
+
* matters most here. The permission prompt takes as long as the person takes
|
|
782
|
+
* to read it, and claiming the call before they have answered it would put
|
|
783
|
+
* somebody on a conversation they may yet decline to be able to speak on —
|
|
784
|
+
* with a member of the team on the other end saying hello to a muted browser.
|
|
785
|
+
*/
|
|
786
|
+
async #answer(bugId, call) {
|
|
787
|
+
this.ringing = null;
|
|
788
|
+
if (this.call || this.callStarting) return;
|
|
789
|
+
this.callStarting = true;
|
|
790
|
+
|
|
791
|
+
let microphone;
|
|
792
|
+
try {
|
|
793
|
+
microphone = await openMicrophone();
|
|
794
|
+
} catch (err) {
|
|
795
|
+
this.callStarting = false;
|
|
796
|
+
// Refused the prompt, or no microphone on this machine. The call is still
|
|
797
|
+
// ringing at the other end, so it is declined rather than left — the
|
|
798
|
+
// member calling should not wait out the deadline for somebody who
|
|
799
|
+
// cannot speak to them.
|
|
800
|
+
declineIncomingCall({ apiBase: this.config.apiBase, bugId, callId: call.callId });
|
|
801
|
+
this.emit('incoming-call', { bugId, callId: call.callId, state: 'failed', error: err });
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
let answered;
|
|
806
|
+
try {
|
|
807
|
+
answered = await answerIncomingCall({
|
|
808
|
+
apiBase: this.config.apiBase, bugId, callId: call.callId,
|
|
809
|
+
});
|
|
810
|
+
} catch (err) {
|
|
811
|
+
for (const track of microphone.getTracks()) track.stop();
|
|
812
|
+
this.callStarting = false;
|
|
813
|
+
// 409 is the ordinary one: it stopped ringing while Answer was being
|
|
814
|
+
// pressed. Reported as a state rather than thrown, because nobody called
|
|
815
|
+
// this — the page did, on somebody's behalf, and there is no promise
|
|
816
|
+
// waiting to reject.
|
|
817
|
+
this.emit('incoming-call', { bugId, callId: call.callId, state: 'missed', error: err });
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
try {
|
|
822
|
+
const handle = await joinLiveCall({
|
|
823
|
+
realtimeUrl: answered.realtime_url,
|
|
824
|
+
stream: microphone,
|
|
825
|
+
// No deadline on this side. The ringing is over — it ended when this
|
|
826
|
+
// browser claimed the call — and a countdown here would hang up on a
|
|
827
|
+
// conversation that has already started.
|
|
828
|
+
onState: (state, detail) => {
|
|
829
|
+
if (state === 'ended' || state === 'failed' || state === 'missed') this.call = null;
|
|
830
|
+
if (typeof this.config.onCallState === 'function') {
|
|
831
|
+
this.config.onCallState(state, { bugId, live: true, incoming: true, ...detail });
|
|
832
|
+
}
|
|
833
|
+
this.emit('call', { bugId, live: true, incoming: true, state, ...detail });
|
|
834
|
+
},
|
|
835
|
+
});
|
|
836
|
+
this.call = handle;
|
|
837
|
+
this.emit('incoming-call', { bugId, callId: call.callId, state: 'answered' });
|
|
838
|
+
} catch (err) {
|
|
839
|
+
for (const track of microphone.getTracks()) track.stop();
|
|
840
|
+
this.emit('incoming-call', { bugId, callId: call.callId, state: 'failed', error: err });
|
|
841
|
+
} finally {
|
|
842
|
+
this.callStarting = false;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* End the call in progress, of either kind. Does nothing when there is not
|
|
848
|
+
* one — there is a single slot, because there is a single microphone.
|
|
849
|
+
*/
|
|
850
|
+
endCall() {
|
|
851
|
+
this.call?.hangUp();
|
|
852
|
+
this.call = null;
|
|
853
|
+
return this;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Everything this browser has reported, newest first, with the status of
|
|
858
|
+
* each — the history a reporter normally has no way to see.
|
|
859
|
+
*
|
|
860
|
+
* const { reports } = await BugCatch.myReports();
|
|
861
|
+
* // [{ reference: '3F2A9B01', title, status, priority, createdAt, ... }]
|
|
862
|
+
*
|
|
863
|
+
* Resolves to an empty history rather than throwing when nothing has been
|
|
864
|
+
* filed from this browser, or when identify() supplied no email — there is
|
|
865
|
+
* nothing to look up in either case.
|
|
866
|
+
*/
|
|
867
|
+
myReports() {
|
|
868
|
+
return fetchMyReports({ apiBase: this.config.apiBase });
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** True when this browser has filed something, so a "your reports" entry
|
|
872
|
+
* point can be hidden until it would show anything. */
|
|
873
|
+
hasReports() {
|
|
874
|
+
return Boolean(readToken());
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Forget this browser's reports — call on sign-out, or the next person at
|
|
879
|
+
* this machine inherits the previous one's notifications.
|
|
880
|
+
*/
|
|
881
|
+
forgetReports() {
|
|
882
|
+
this.stopWatching?.();
|
|
883
|
+
this.stopWatching = null;
|
|
884
|
+
// Ended, not just forgotten. This is the sign-out path, and a call left
|
|
885
|
+
// running on it would keep a microphone open on behalf of somebody who has
|
|
886
|
+
// just left — and go on transcribing them into a report they can no longer
|
|
887
|
+
// prove is theirs.
|
|
888
|
+
this.endCall();
|
|
889
|
+
// Drops the unread notifications with the token — see clearToken — so the
|
|
890
|
+
// next person at a shared machine does not find the last one's bug reports
|
|
891
|
+
// stacked in the corner.
|
|
892
|
+
clearToken();
|
|
893
|
+
this.#drawUnread();
|
|
894
|
+
return this;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Attach the signed-in user so reports say who hit the bug.
|
|
899
|
+
*
|
|
900
|
+
* `email` is the field that matters beyond display: it threads every report
|
|
901
|
+
* from the same person together in the dashboard, and it is where the team's
|
|
902
|
+
* "your issue is resolved" email goes. Because your app has already
|
|
903
|
+
* authenticated this person, an address sent here is treated as reachable —
|
|
904
|
+
* unlike one typed into a public recording-link form, which stays unusable
|
|
905
|
+
* for outbound mail until the reporter answers a confirmation code.
|
|
906
|
+
*
|
|
907
|
+
* BugCatch.identify({ id: 'u_42', email: 'alex@acme.com', name: 'Alex' });
|
|
908
|
+
*
|
|
909
|
+
* Pass null to forget them again on sign-out.
|
|
910
|
+
*/
|
|
911
|
+
identify(user) {
|
|
912
|
+
this.config.user = user;
|
|
913
|
+
return this;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Upload a screenshot, recording or file and get back the storage key that
|
|
918
|
+
* `report({ attachments })` references.
|
|
919
|
+
*
|
|
920
|
+
* The blob goes straight to storage on a presigned URL — it never passes
|
|
921
|
+
* through the API — so this is also what you call when you drive the SDK
|
|
922
|
+
* yourself with `widget: false`.
|
|
923
|
+
*/
|
|
924
|
+
async uploadAttachment(blob, mime, onProgress) {
|
|
925
|
+
if (!this.started) throw new Error('[BugCatch] call init() first.');
|
|
926
|
+
return uploadAttachment({
|
|
927
|
+
apiBase: this.config.apiBase,
|
|
928
|
+
apiKey: this.config.apiKey,
|
|
929
|
+
blob,
|
|
930
|
+
mime: mime || blob?.type,
|
|
931
|
+
onProgress,
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** File a bug with everything captured so far. */
|
|
936
|
+
async report({
|
|
937
|
+
title, description = '', priority = 'MEDIUM', metadata = {}, attachments = [],
|
|
938
|
+
} = {}) {
|
|
939
|
+
if (!this.started) throw new Error('[BugCatch] call init() first.');
|
|
940
|
+
if (!title || !title.trim()) throw new Error('[BugCatch] a title is required.');
|
|
941
|
+
|
|
942
|
+
// Drop anything the API would reject outright, so one bad entry can't cost
|
|
943
|
+
// the reporter the whole report.
|
|
944
|
+
const media = (attachments || [])
|
|
945
|
+
// REPORT_MIMES, not the full upload set: a report's attachments are typed
|
|
946
|
+
// SCREENSHOT or VIDEO, so a document reaching here would be filed as a
|
|
947
|
+
// screenshot. Documents and voice notes belong on a message.
|
|
948
|
+
.filter((a) => a?.storageKey && normalizeMime(a.mime, REPORT_MIMES))
|
|
949
|
+
.map((a) => ({
|
|
950
|
+
type: a.type === 'VIDEO' ? 'VIDEO' : 'SCREENSHOT',
|
|
951
|
+
storageKey: a.storageKey,
|
|
952
|
+
mime: normalizeMime(a.mime, REPORT_MIMES),
|
|
953
|
+
}));
|
|
954
|
+
|
|
955
|
+
let payload = await this.#payload({ title: title.trim(), description, priority, metadata, media });
|
|
956
|
+
|
|
957
|
+
if (typeof this.config.beforeSend === 'function') {
|
|
958
|
+
const result = this.config.beforeSend(payload);
|
|
959
|
+
if (result === false) return { cancelled: true };
|
|
960
|
+
if (result && typeof result === 'object') payload = result;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const session = readSession(this.config.projectId);
|
|
964
|
+
const res = await fetch(`${this.config.apiBase}/ingest/bugs`, {
|
|
965
|
+
method: 'POST',
|
|
966
|
+
headers: {
|
|
967
|
+
'Content-Type': 'application/json',
|
|
968
|
+
'X-BugCatch-Key': this.config.apiKey,
|
|
969
|
+
// Present only where the reporter signed in. The server takes the
|
|
970
|
+
// address from here rather than from the payload when it is — on a
|
|
971
|
+
// project that requires sign-in, letting the page name a different
|
|
972
|
+
// reporter would undo the requirement.
|
|
973
|
+
...(session?.token ? { 'X-BugCatch-Session': session.token } : {}),
|
|
974
|
+
},
|
|
975
|
+
body: JSON.stringify(payload),
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
// A session the server no longer recognises — expired, or ended by a
|
|
979
|
+
// sign-in somewhere else. Dropped here rather than left to fail again on
|
|
980
|
+
// the next report: the widget re-reads state every time it opens, so
|
|
981
|
+
// forgetting it now is what makes it draw the sign-in step instead.
|
|
982
|
+
if (res.status === 401 && session) clearSession(this.config.projectId);
|
|
983
|
+
|
|
984
|
+
if (!res.ok) {
|
|
985
|
+
let detail = '';
|
|
986
|
+
try {
|
|
987
|
+
const body = await res.json();
|
|
988
|
+
detail = Array.isArray(body.message) ? body.message.join(', ') : body.message || '';
|
|
989
|
+
} catch { /* non-JSON error */ }
|
|
990
|
+
throw new Error(`[BugCatch] report failed (HTTP ${res.status})${detail ? ': ' + detail : ''}`);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const result = await res.json();
|
|
994
|
+
|
|
995
|
+
// The API returns a watch token only when it could attribute the report to
|
|
996
|
+
// someone — which means identify() was called with an email. Without one
|
|
997
|
+
// there is nobody to notify, and none comes back.
|
|
998
|
+
if (result.watchToken) {
|
|
999
|
+
writeToken(result.watchToken);
|
|
1000
|
+
if (this.config.updates) this.startWatching();
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return result;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* What this project asks of the person reporting, and who this browser is
|
|
1008
|
+
* signed in as right now.
|
|
1009
|
+
*
|
|
1010
|
+
* const { mode, signedIn } = await BugCatch.loginState();
|
|
1011
|
+
* // mode: 'off' | 'optional' | 'required'
|
|
1012
|
+
*
|
|
1013
|
+
* The widget calls this itself every time it opens. It is here for
|
|
1014
|
+
* integrations running with `widget: false`, which have to draw their own
|
|
1015
|
+
* sign-in and need the same answer to know whether to draw one at all.
|
|
1016
|
+
*
|
|
1017
|
+
* The mode is the project's setting, changed in BugCatch under Project
|
|
1018
|
+
* Settings → General, not in this config. A widget that could opt out of a
|
|
1019
|
+
* required sign-in would not be a requirement.
|
|
1020
|
+
*/
|
|
1021
|
+
loginState() {
|
|
1022
|
+
return loginState({
|
|
1023
|
+
apiBase: this.config.apiBase,
|
|
1024
|
+
apiKey: this.config.apiKey,
|
|
1025
|
+
projectId: this.config.projectId,
|
|
1026
|
+
token: readSession(this.config.projectId)?.token,
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Who this browser is signed in as on this project, from storage alone.
|
|
1032
|
+
*
|
|
1033
|
+
* BugCatch.signedInAs() // { email, name } | null
|
|
1034
|
+
*
|
|
1035
|
+
* Synchronous, and therefore only as current as the last round trip: a
|
|
1036
|
+
* session ended elsewhere still reads as signed in here until something asks
|
|
1037
|
+
* the server. Use loginState() where that matters.
|
|
1038
|
+
*/
|
|
1039
|
+
signedInAs() {
|
|
1040
|
+
const session = readSession(this.config.projectId);
|
|
1041
|
+
return session ? { email: session.email, name: session.name } : null;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Step one of signing in: mail a code to any address.
|
|
1046
|
+
*
|
|
1047
|
+
* await BugCatch.signIn('someone@example.com');
|
|
1048
|
+
* await BugCatch.confirmSignIn('someone@example.com', '481920');
|
|
1049
|
+
*
|
|
1050
|
+
* Any address, deliberately — there is no list of people who may report a
|
|
1051
|
+
* bug in somebody's product, and asking for one would be asking the customer
|
|
1052
|
+
* to maintain it. What the code proves is that whoever typed it can read the
|
|
1053
|
+
* address, which is all a reply-to has ever needed to be worth.
|
|
1054
|
+
*/
|
|
1055
|
+
signIn(email, { name } = {}) {
|
|
1056
|
+
return startSignIn({
|
|
1057
|
+
apiBase: this.config.apiBase,
|
|
1058
|
+
apiKey: this.config.apiKey,
|
|
1059
|
+
projectId: this.config.projectId,
|
|
1060
|
+
email,
|
|
1061
|
+
// The name the host app already knows beats asking for one again.
|
|
1062
|
+
name: name ?? this.config.user?.name ?? null,
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Step two: spend the code and hold the session.
|
|
1068
|
+
*
|
|
1069
|
+
* Signing in signs this address out of this project in every other browser —
|
|
1070
|
+
* one live session per address per project. Somebody who signs in on their
|
|
1071
|
+
* phone has signed their laptop out, on purpose.
|
|
1072
|
+
*/
|
|
1073
|
+
async confirmSignIn(email, code) {
|
|
1074
|
+
const session = await confirmSignIn({
|
|
1075
|
+
apiBase: this.config.apiBase,
|
|
1076
|
+
apiKey: this.config.apiKey,
|
|
1077
|
+
projectId: this.config.projectId,
|
|
1078
|
+
email,
|
|
1079
|
+
code,
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
// A signed-in reporter is an identity, so reports carry it the same way
|
|
1083
|
+
// identify() makes them — but never over one the host application set:
|
|
1084
|
+
// that address is its own claim about its own user, and this SDK is not
|
|
1085
|
+
// the thing that decides it was wrong.
|
|
1086
|
+
if (!this.config.user?.email) {
|
|
1087
|
+
this.config.user = { email: session.email, name: session.name || undefined };
|
|
1088
|
+
}
|
|
1089
|
+
this.emit('signin', { email: session.email, name: session.name });
|
|
1090
|
+
return { email: session.email, name: session.name };
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Sign out of this project.
|
|
1095
|
+
*
|
|
1096
|
+
* Ends the session on the server too, so the token cannot be replayed from a
|
|
1097
|
+
* copy of this browser's storage. Local state goes whatever the network
|
|
1098
|
+
* says — sign-out on a shared machine has to mean signed out on it.
|
|
1099
|
+
*/
|
|
1100
|
+
async signOut() {
|
|
1101
|
+
const session = readSession(this.config.projectId);
|
|
1102
|
+
const result = await endSignIn({
|
|
1103
|
+
apiBase: this.config.apiBase,
|
|
1104
|
+
apiKey: this.config.apiKey,
|
|
1105
|
+
projectId: this.config.projectId,
|
|
1106
|
+
token: session?.token,
|
|
1107
|
+
});
|
|
1108
|
+
// Only the identity this session put there. One the host application set
|
|
1109
|
+
// through identify() is theirs, and outlives a widget sign-out.
|
|
1110
|
+
if (session?.email && this.config.user?.email === session.email) this.config.user = null;
|
|
1111
|
+
this.emit('signout', { email: session?.email || null });
|
|
1112
|
+
return result;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** What would be sent right now — useful for debugging integrations. */
|
|
1116
|
+
/**
|
|
1117
|
+
* What this project is already known to be having trouble with.
|
|
1118
|
+
*
|
|
1119
|
+
* The widget asks this itself when it opens; it is public for a host
|
|
1120
|
+
* application drawing its own help surface with `widget: false`. Resolves to
|
|
1121
|
+
* an empty list on every project that has not switched the feature on, which
|
|
1122
|
+
* is the default — draw nothing rather than an empty heading.
|
|
1123
|
+
*/
|
|
1124
|
+
knownIssues() {
|
|
1125
|
+
return fetchKnownIssues({
|
|
1126
|
+
apiBase: this.config.apiBase,
|
|
1127
|
+
apiKey: this.config.apiKey,
|
|
1128
|
+
projectId: this.config.projectId,
|
|
1129
|
+
sessionToken: readSession(this.config.projectId)?.token,
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* The known issues that look like a description somebody is writing.
|
|
1135
|
+
*
|
|
1136
|
+
* Pass an AbortSignal when calling this on a keystroke, or a fast typist gets
|
|
1137
|
+
* the results of whichever request happens to return last.
|
|
1138
|
+
*/
|
|
1139
|
+
matchKnownIssues(query, { signal } = {}) {
|
|
1140
|
+
return matchKnownIssues({
|
|
1141
|
+
apiBase: this.config.apiBase,
|
|
1142
|
+
apiKey: this.config.apiKey,
|
|
1143
|
+
projectId: this.config.projectId,
|
|
1144
|
+
q: query || '',
|
|
1145
|
+
sessionToken: readSession(this.config.projectId)?.token,
|
|
1146
|
+
signal,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Say a known issue is happening to you too, instead of filing a second
|
|
1152
|
+
* report about it.
|
|
1153
|
+
*
|
|
1154
|
+
* Sends the same capture a report would — this browser's console, network and
|
|
1155
|
+
* environment — because that is what the team gets out of somebody joining
|
|
1156
|
+
* rather than filing: one report with everybody's environment on it.
|
|
1157
|
+
*
|
|
1158
|
+
* Needs an address. A signed-in widget has one, and identify() supplies one
|
|
1159
|
+
* otherwise; pass `email` for the case where neither did. Joining means "tell
|
|
1160
|
+
* me when this is fixed", and there is no way to keep that promise to
|
|
1161
|
+
* somebody anonymous.
|
|
1162
|
+
*/
|
|
1163
|
+
async meToo(bugId, { email, name } = {}) {
|
|
1164
|
+
const session = readSession(this.config.projectId);
|
|
1165
|
+
const environment = await collectEnvironment();
|
|
1166
|
+
return joinKnownIssue({
|
|
1167
|
+
apiBase: this.config.apiBase,
|
|
1168
|
+
apiKey: this.config.apiKey,
|
|
1169
|
+
projectId: this.config.projectId,
|
|
1170
|
+
bugId,
|
|
1171
|
+
reporterEmail: email || this.config.user?.email,
|
|
1172
|
+
reporterName: name || this.config.user?.name,
|
|
1173
|
+
metadata: { ...environment, user: this.config.user || undefined, sdk: SDK_IDENTITY },
|
|
1174
|
+
logs: {
|
|
1175
|
+
console: { entries: this.consoleBuffer.all() },
|
|
1176
|
+
network: { entries: this.networkBuffer.all() },
|
|
1177
|
+
},
|
|
1178
|
+
sessionToken: session?.token,
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
snapshot() {
|
|
1183
|
+
return {
|
|
1184
|
+
console: this.consoleBuffer.all(),
|
|
1185
|
+
network: this.networkBuffer.all(),
|
|
1186
|
+
environment: collectEnvironmentSync(),
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
clear() {
|
|
1191
|
+
this.consoleBuffer.clear();
|
|
1192
|
+
this.networkBuffer.clear();
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/** Restore every patched global and remove the widget. */
|
|
1196
|
+
destroy() {
|
|
1197
|
+
// The call first. Everything else here is listeners and buffers; this one
|
|
1198
|
+
// holds a live microphone, and a page that tore the SDK down without
|
|
1199
|
+
// releasing it would keep the tab's recording indicator lit.
|
|
1200
|
+
this.endCall();
|
|
1201
|
+
this.teardown.forEach((fn) => { try { fn(); } catch { /* ignore */ } });
|
|
1202
|
+
this.teardown = [];
|
|
1203
|
+
// Torn down with everything else, so a later init() builds a fresh one
|
|
1204
|
+
// rather than rendering into a stack that has been removed.
|
|
1205
|
+
this.notifier = null;
|
|
1206
|
+
this.started = false;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const BugCatch = new BugCatchClient();
|
|
1211
|
+
|
|
1212
|
+
// Exposed on the singleton rather than as a second named export: with a UMD
|
|
1213
|
+
// build, a named export alongside the default turns `window.BugCatch` into a
|
|
1214
|
+
// namespace object, so `BugCatch.init(...)` from a <script> tag would break.
|
|
1215
|
+
BugCatch.Client = BugCatchClient;
|
|
1216
|
+
|
|
1217
|
+
export default BugCatch;
|