@sphoro/bugcatch-web-sdk 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -74
- package/dist/bugcatch.es.js +1752 -1685
- package/dist/bugcatch.es.js.map +1 -1
- package/dist/bugcatch.umd.js +154 -110
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/drafts.js +32 -0
- package/src/index.d.ts +114 -75
- package/src/index.js +147 -164
- package/src/references.js +172 -0
- package/src/updates.js +45 -74
- package/src/widget.js +320 -105
- package/src/voice.js +0 -372
package/src/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { mountWidget } from './widget.js';
|
|
|
6
6
|
import { uploadAttachment, normalizeMime, REPORT_MIMES } from './capture.js';
|
|
7
7
|
import {
|
|
8
8
|
watchUpdates, createUpdateNotifier, fetchMyReports, fetchReport, writeToken, readToken,
|
|
9
|
-
clearToken, sendMessage,
|
|
9
|
+
clearToken, sendMessage, verifyFix, callKindsForProject,
|
|
10
10
|
reportByCall, liveCallAvailable, requestLiveCall, giveUpLiveCall,
|
|
11
11
|
answerIncomingCall, declineIncomingCall,
|
|
12
12
|
readUnread, rememberUpdate, forgetUpdate, forgetReportUpdates, forgetAllUpdates,
|
|
@@ -16,9 +16,9 @@ import {
|
|
|
16
16
|
import {
|
|
17
17
|
loginState, startSignIn, confirmSignIn, endSignIn, readSession, clearSession,
|
|
18
18
|
} from './session.js';
|
|
19
|
-
import { startVoiceCall } from './voice.js';
|
|
20
19
|
import { joinLiveCall, liveCallSupported, openMicrophone } from './live-call.js';
|
|
21
20
|
import { showIncomingCall } from './incoming-call.js';
|
|
21
|
+
import { referenceFields } from './references.js';
|
|
22
22
|
|
|
23
23
|
// Replaced at build time from package.json. The guard is for anyone importing
|
|
24
24
|
// src/ directly, where the define never runs.
|
|
@@ -63,16 +63,14 @@ const DEFAULTS = {
|
|
|
63
63
|
// reports moves. Runs whether or not the built-in notification is on; `id` is
|
|
64
64
|
// what markUpdateRead() takes.
|
|
65
65
|
onUpdate: null,
|
|
66
|
-
// Offer a
|
|
66
|
+
// Offer a call to the team when a report is picked up, if the workspace has
|
|
67
67
|
// sphoro.voice configured. Nothing is dialled and no permission is asked for
|
|
68
68
|
// until somebody presses the button — see startCall().
|
|
69
69
|
voice: true,
|
|
70
|
-
// Called as a call moves: '
|
|
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
|
|
70
|
+
// Called as a call moves: 'ringing', 'connecting', 'connected', 'missed',
|
|
71
|
+
// 'ended', 'failed'. The SDK shows nothing of its own beyond the offer, so an
|
|
72
|
+
// integration that wants a call UI builds it from this.
|
|
73
73
|
onCallState: null,
|
|
74
|
-
// Called with { role, text, final } for each line, as it is recognised.
|
|
75
|
-
onCallTranscript: null,
|
|
76
74
|
// Called with { bugId, callId, from, project, reference, ringsUntil,
|
|
77
75
|
// recorded } when somebody on the team rings this page about a report filed
|
|
78
76
|
// from it. The SDK has already drawn the card by then — this is for a host
|
|
@@ -324,6 +322,11 @@ class BugCatchClient {
|
|
|
324
322
|
*
|
|
325
323
|
* Resolves to null when this browser cannot prove it filed that report.
|
|
326
324
|
*/
|
|
325
|
+
async verifyFix(bugId, { result, resolvedAt, note }) {
|
|
326
|
+
const context = result === 'STILL_HAPPENING' ? await this.#payload({}) : {};
|
|
327
|
+
return verifyFix({ apiBase: this.config.apiBase, bugId, result, resolvedAt, note, metadata: context.metadata, logs: context.logs });
|
|
328
|
+
}
|
|
329
|
+
|
|
327
330
|
myReport(bugId) {
|
|
328
331
|
return fetchReport({ apiBase: this.config.apiBase, bugId });
|
|
329
332
|
}
|
|
@@ -349,11 +352,11 @@ class BugCatchClient {
|
|
|
349
352
|
* Everything a report carries, however it was started.
|
|
350
353
|
*
|
|
351
354
|
* Shared by `report()` and by pressing Call, because the two file the same
|
|
352
|
-
* thing — the difference is only whether a person typed
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
+
* thing — the difference is only whether a person typed a title first. Two
|
|
356
|
+
* copies of this would drift, and the half that drifted would be the one that
|
|
357
|
+
* stopped attaching the console.
|
|
355
358
|
*/
|
|
356
|
-
async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [] }) {
|
|
359
|
+
async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [], references = [] }) {
|
|
357
360
|
return {
|
|
358
361
|
projectId: this.config.projectId,
|
|
359
362
|
...(title ? { title } : {}),
|
|
@@ -366,6 +369,9 @@ class BugCatchClient {
|
|
|
366
369
|
reporterName: this.config.user?.name,
|
|
367
370
|
metadata: {
|
|
368
371
|
...(await collectEnvironment()),
|
|
372
|
+
release: this.config.release,
|
|
373
|
+
environment: this.config.environment,
|
|
374
|
+
buildId: this.config.buildId,
|
|
369
375
|
...metadata,
|
|
370
376
|
user: this.config.user || undefined,
|
|
371
377
|
sdk: SDK_IDENTITY,
|
|
@@ -377,18 +383,23 @@ class BugCatchClient {
|
|
|
377
383
|
// Omitted rather than sent empty: the field is optional server-side, and
|
|
378
384
|
// an empty array in every payload makes beforeSend hooks harder to read.
|
|
379
385
|
...(media.length ? { attachments: media } : {}),
|
|
386
|
+
// What the reporter answered when asked which record this is about. Only
|
|
387
|
+
// ever what they typed into the box — never what this browser matched in
|
|
388
|
+
// their own text, which the API reads for itself out of the title and
|
|
389
|
+
// description it is being sent anyway. A payload is a claim, and there is
|
|
390
|
+
// no reason to make the join key one when the source text is right here.
|
|
391
|
+
...(references.length ? { references } : {}),
|
|
380
392
|
};
|
|
381
393
|
}
|
|
382
394
|
|
|
383
395
|
/**
|
|
384
396
|
* File a report because somebody pressed Call instead of filling the form in.
|
|
385
397
|
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
* pressing Call was told to sign in.
|
|
398
|
+
* Filing is the half a call shares with the form, and it is the half that has
|
|
399
|
+
* to say who is filing. Held in one place after a session header went missing
|
|
400
|
+
* from this route while `report()` kept sending it: nothing looked wrong until
|
|
401
|
+
* a project switched sign-in on, and then a signed-in reporter pressing Call
|
|
402
|
+
* was told to sign in.
|
|
392
403
|
*/
|
|
393
404
|
async #fileByCall() {
|
|
394
405
|
const session = readSession(this.config.projectId);
|
|
@@ -426,25 +437,23 @@ class BugCatchClient {
|
|
|
426
437
|
* Two conditions, and both have to hold before anything is drawn. The project
|
|
427
438
|
* has to have calling configured — false on most deployments — and this
|
|
428
439
|
* 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
|
|
430
|
-
*
|
|
431
|
-
* feature in this SDK has: it all hangs off `identify()`.
|
|
440
|
+
* attribute it to has no watch token to follow it with and nobody to tell
|
|
441
|
+
* afterwards what happened to the report. That is the same limit every other
|
|
442
|
+
* follow-up feature in this SDK has: it all hangs off `identify()`.
|
|
432
443
|
*/
|
|
433
444
|
async canCallNow() {
|
|
434
445
|
return Boolean(await this.callKind());
|
|
435
446
|
}
|
|
436
447
|
|
|
437
448
|
/**
|
|
438
|
-
* Which kind of call this visitor would get: '
|
|
449
|
+
* Which kind of call this visitor would get: 'live', or null for none.
|
|
439
450
|
*
|
|
440
451
|
* const kind = await BugCatch.callKind();
|
|
441
|
-
* // '
|
|
442
|
-
* // 'live' — the team's dashboards ring and a colleague picks up
|
|
452
|
+
* // 'live' — the team's dashboards ring and a colleague picks up
|
|
443
453
|
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
* button has to match which one it is.
|
|
454
|
+
* One kind, and this says whether it is on offer. It is still worth asking
|
|
455
|
+
* rather than assuming: on most deployments the answer is null, and a button
|
|
456
|
+
* promising a call nobody can place is worse than no button.
|
|
448
457
|
*
|
|
449
458
|
* The answer is remembered for the life of the page. It is a property of the
|
|
450
459
|
* deployment's configuration rather than of this visitor, and re-asking on
|
|
@@ -452,15 +461,36 @@ class BugCatchClient {
|
|
|
452
461
|
* about once a year.
|
|
453
462
|
*/
|
|
454
463
|
async callKind() {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
464
|
+
const kinds = await this.callKinds();
|
|
465
|
+
return kinds.live ? 'live' : null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The same answer as booleans: `{ agent, live }`.
|
|
470
|
+
*
|
|
471
|
+
* const { live } = await BugCatch.callKinds();
|
|
472
|
+
* if (live) drawCallTheTeam();
|
|
473
|
+
*
|
|
474
|
+
* `agent` is always false. It was a triage agent that answered instantly
|
|
475
|
+
* instead of a person, it is gone, and the key stays only so an integration
|
|
476
|
+
* that reads it does not get `undefined` and draw a button on it.
|
|
477
|
+
*
|
|
478
|
+
* Same round trip and same page-lifetime cache as `callKind()`. False on a
|
|
479
|
+
* deployment without sphoro.voice, on a workspace that has switched calling
|
|
480
|
+
* off, and on a visitor this SDK has no address for — a call needs somebody
|
|
481
|
+
* to attribute it to.
|
|
482
|
+
*/
|
|
483
|
+
async callKinds() {
|
|
484
|
+
const none = { agent: false, live: false };
|
|
485
|
+
if (!this.config.voice) return none;
|
|
486
|
+
if (!this.config.user?.email) return none;
|
|
487
|
+
if (this.kinds !== undefined) return this.kinds;
|
|
488
|
+
this.kinds = await callKindsForProject({
|
|
459
489
|
apiBase: this.config.apiBase,
|
|
460
490
|
apiKey: this.config.apiKey,
|
|
461
491
|
projectId: this.config.projectId,
|
|
462
492
|
});
|
|
463
|
-
return this.
|
|
493
|
+
return this.kinds;
|
|
464
494
|
}
|
|
465
495
|
|
|
466
496
|
/**
|
|
@@ -472,174 +502,98 @@ class BugCatchClient {
|
|
|
472
502
|
* workspace that has not switched calling on, and on a report that has had
|
|
473
503
|
* its allowance of calls — so an integration rendering its own affordance can
|
|
474
504
|
* ask rather than guess.
|
|
505
|
+
*
|
|
506
|
+
* The same question as `canCallTeam`, which is the name the widget uses. Both
|
|
507
|
+
* are kept because both are documented, and there is one kind of call left
|
|
508
|
+
* for either of them to be asking about.
|
|
475
509
|
*/
|
|
476
510
|
canCall(bugId) {
|
|
477
511
|
if (!this.config.voice) return Promise.resolve(false);
|
|
478
|
-
return
|
|
512
|
+
return liveCallAvailable({ apiBase: this.config.apiBase, bugId });
|
|
479
513
|
}
|
|
480
514
|
|
|
481
515
|
/**
|
|
482
|
-
*
|
|
516
|
+
* Ring the team.
|
|
483
517
|
*
|
|
484
518
|
* await BugCatch.startCall(id); // about a report already filed
|
|
485
519
|
* await BugCatch.startCall(); // instead of filling the form in
|
|
486
520
|
*
|
|
487
|
-
* With a report id, it
|
|
521
|
+
* With a report id, it rings about that report. With none, it files one first
|
|
488
522
|
* — carrying the console, network and environment this SDK is already holding
|
|
489
|
-
* — and
|
|
490
|
-
*
|
|
491
|
-
*
|
|
523
|
+
* — and rings about that. The report is written before the phones ring either
|
|
524
|
+
* way, so a call nobody answers still leaves the context behind; the handle
|
|
525
|
+
* carries `bugId` so the caller can say what it was filed as.
|
|
492
526
|
*
|
|
493
|
-
* Asks for the microphone,
|
|
494
|
-
*
|
|
495
|
-
*
|
|
527
|
+
* Asks for the microphone, places the call, and resolves once it is
|
|
528
|
+
* *ringing* — not once somebody answers. The handle's `onState` is how the
|
|
529
|
+
* caller follows what happens after that:
|
|
530
|
+
*
|
|
531
|
+
* 'ringing' the team's dashboards are lighting up
|
|
532
|
+
* 'connecting' somebody answered; the two browsers are finding each other
|
|
533
|
+
* 'connected' they can hear each other
|
|
534
|
+
* 'missed' nobody picked up before the ringing stopped
|
|
535
|
+
* 'ended' it is over
|
|
536
|
+
*
|
|
537
|
+
* What comes back is a conversation with a colleague: the audio travels
|
|
538
|
+
* directly between the two browsers over WebRTC and never reaches bugcatch or
|
|
539
|
+
* sphoro.voice, so it is not transcribed. The thread gets a note saying the
|
|
540
|
+
* call happened and who took it.
|
|
496
541
|
*
|
|
497
542
|
* Rejects if the microphone is refused, if calling is not available, or if
|
|
498
543
|
* one is already running. There is deliberately no way to start a second: two
|
|
499
|
-
* microphones on one page talk over each other
|
|
500
|
-
*
|
|
501
|
-
* written and only the call failed.
|
|
544
|
+
* microphones on one page talk over each other. A rejection carrying `.filed`
|
|
545
|
+
* means the report was written and only the call failed.
|
|
502
546
|
*/
|
|
503
547
|
async startCall(bugId) {
|
|
504
548
|
if (!this.config.voice) throw new Error('[BugCatch] calling is switched off.');
|
|
505
549
|
// Two checks, because there are two ways to already be on a call: one that
|
|
506
550
|
// has connected, and one still asking for the microphone. A double-click
|
|
507
551
|
// 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
|
|
509
|
-
//
|
|
552
|
+
// while the first is still awaiting, and the page ends up with two
|
|
553
|
+
// microphones in one conversation.
|
|
510
554
|
if (this.call || this.callStarting) {
|
|
511
555
|
throw new Error('[BugCatch] a call is already running.');
|
|
512
556
|
}
|
|
513
557
|
this.callStarting = true;
|
|
514
558
|
|
|
515
559
|
try {
|
|
516
|
-
|
|
560
|
+
// Whether this deployment can place a call at all, asked before the
|
|
561
|
+
// microphone prompt and before anything is filed. Without it a press from
|
|
562
|
+
// a visitor with no address, or on a project with calling off, filed a
|
|
563
|
+
// report anyway — and the reporter, promised a call, got a ticket and a
|
|
564
|
+
// phone that never rang.
|
|
565
|
+
if (!await this.callKind()) {
|
|
566
|
+
throw new Error(this.config.user?.email
|
|
567
|
+
? '[BugCatch] calling is not available on this project.'
|
|
568
|
+
: '[BugCatch] a call needs to know who is calling — call identify() with an email first.');
|
|
569
|
+
}
|
|
570
|
+
return await this.#ringTheTeam(bugId);
|
|
517
571
|
} finally {
|
|
518
572
|
this.callStarting = false;
|
|
519
573
|
}
|
|
520
574
|
}
|
|
521
575
|
|
|
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
576
|
/**
|
|
585
|
-
*
|
|
577
|
+
* Ring the team, under the name an integration may already be calling.
|
|
586
578
|
*
|
|
587
579
|
* await BugCatch.startLiveCall(id);
|
|
588
580
|
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
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.
|
|
581
|
+
* Exactly `startCall`, which is now the only call there is. The id is
|
|
582
|
+
* optional here too: it was required while this was one of two kinds, because
|
|
583
|
+
* the other one was the only way to call without a report to call about.
|
|
613
584
|
*/
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
}
|
|
585
|
+
startLiveCall(bugId) {
|
|
586
|
+
return this.startCall(bugId);
|
|
633
587
|
}
|
|
634
588
|
|
|
635
|
-
/** The body of
|
|
589
|
+
/** The body of startCall, so the in-flight flag clears on every path. */
|
|
636
590
|
async #ringTheTeam(bugId) {
|
|
637
591
|
// Before the call is placed, not after. A permission prompt takes as long
|
|
638
592
|
// as the person takes to read it, and asking afterwards would have a room
|
|
639
593
|
// full of dashboards ringing about a call its own caller may yet refuse to
|
|
640
594
|
// be on. That ordering is why the report below is filed *after* the
|
|
641
|
-
// microphone too,
|
|
642
|
-
//
|
|
595
|
+
// microphone too, rather than first as the form does: filing is what makes
|
|
596
|
+
// the phones ring.
|
|
643
597
|
if (!liveCallSupported()) {
|
|
644
598
|
throw new Error('[BugCatch] this browser cannot make live calls.');
|
|
645
599
|
}
|
|
@@ -652,16 +606,18 @@ class BugCatchClient {
|
|
|
652
606
|
started = await requestLiveCall({ apiBase: this.config.apiBase, bugId });
|
|
653
607
|
} else {
|
|
654
608
|
// Nobody filled the form in. One round trip writes what the page was
|
|
655
|
-
// doing and rings the team about it
|
|
656
|
-
//
|
|
609
|
+
// doing and rings the team about it, so a call nobody answers still
|
|
610
|
+
// leaves the context behind.
|
|
657
611
|
filed = await this.#fileByCall();
|
|
658
612
|
bugId = filed.id;
|
|
659
613
|
started = filed.live_call;
|
|
660
614
|
if (!started?.realtime_url) {
|
|
661
615
|
// Filed, but nobody rang. Reported as such rather than thrown, so the
|
|
662
616
|
// caller can say what the report is called instead of losing it.
|
|
663
|
-
const err = new Error(
|
|
617
|
+
const err = new Error(`[BugCatch] the report was filed, but the team could not be rung${
|
|
618
|
+
filed.call_error ? `: ${filed.call_error}` : '.'}`);
|
|
664
619
|
err.filed = filed;
|
|
620
|
+
err.reason = filed.call_error || null;
|
|
665
621
|
throw err;
|
|
666
622
|
}
|
|
667
623
|
}
|
|
@@ -689,9 +645,8 @@ class BugCatchClient {
|
|
|
689
645
|
},
|
|
690
646
|
});
|
|
691
647
|
|
|
692
|
-
// Set after the await,
|
|
693
|
-
//
|
|
694
|
-
// block the retry.
|
|
648
|
+
// Set after the await, not before: a call that never connected is not one
|
|
649
|
+
// this page is on, and leaving the slot taken would block the retry.
|
|
695
650
|
this.call = handle;
|
|
696
651
|
return Object.assign(handle, { bugId, callId: started.call_id, live: true, filed });
|
|
697
652
|
}
|
|
@@ -935,6 +890,7 @@ class BugCatchClient {
|
|
|
935
890
|
/** File a bug with everything captured so far. */
|
|
936
891
|
async report({
|
|
937
892
|
title, description = '', priority = 'MEDIUM', metadata = {}, attachments = [],
|
|
893
|
+
references = [],
|
|
938
894
|
} = {}) {
|
|
939
895
|
if (!this.started) throw new Error('[BugCatch] call init() first.');
|
|
940
896
|
if (!title || !title.trim()) throw new Error('[BugCatch] a title is required.');
|
|
@@ -952,7 +908,9 @@ class BugCatchClient {
|
|
|
952
908
|
mime: normalizeMime(a.mime, REPORT_MIMES),
|
|
953
909
|
}));
|
|
954
910
|
|
|
955
|
-
let payload = await this.#payload({
|
|
911
|
+
let payload = await this.#payload({
|
|
912
|
+
title: title.trim(), description, priority, metadata, media, references,
|
|
913
|
+
});
|
|
956
914
|
|
|
957
915
|
if (typeof this.config.beforeSend === 'function') {
|
|
958
916
|
const result = this.config.beforeSend(payload);
|
|
@@ -1027,6 +985,31 @@ class BugCatchClient {
|
|
|
1027
985
|
});
|
|
1028
986
|
}
|
|
1029
987
|
|
|
988
|
+
/**
|
|
989
|
+
* What this project needs in order to look a report up, and how it asks.
|
|
990
|
+
*
|
|
991
|
+
* const { policy, fields } = await BugCatch.referenceFields();
|
|
992
|
+
* // policy: 'off' | 'optional' | 'required'
|
|
993
|
+
*
|
|
994
|
+
* The widget calls this itself when it opens, and caches the answer for the
|
|
995
|
+
* life of the page: unlike the sign-in state, this cannot change underneath a
|
|
996
|
+
* reporter in a way that matters — an admin editing the field list mid-report
|
|
997
|
+
* costs one stale question, not a failed send.
|
|
998
|
+
*
|
|
999
|
+
* Here for the same reason loginState() is: an integration running with
|
|
1000
|
+
* `widget: false` draws its own form and needs the same answer to know
|
|
1001
|
+
* whether that form should have an identifier box on it.
|
|
1002
|
+
*/
|
|
1003
|
+
referenceFields() {
|
|
1004
|
+
if (this.refFields) return this.refFields;
|
|
1005
|
+
this.refFields = referenceFields({
|
|
1006
|
+
apiBase: this.config.apiBase,
|
|
1007
|
+
apiKey: this.config.apiKey,
|
|
1008
|
+
projectId: this.config.projectId,
|
|
1009
|
+
});
|
|
1010
|
+
return this.refFields;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1030
1013
|
/**
|
|
1031
1014
|
* Who this browser is signed in as on this project, from storage alone.
|
|
1032
1015
|
*
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the reporter's own text for something that identifies their record.
|
|
3
|
+
*
|
|
4
|
+
* A browser-side twin of bugcatch-api/src/settings/reference-extract.ts, and
|
|
5
|
+
* deliberately only the half this side needs. The server does the extraction
|
|
6
|
+
* that counts — what a payload claims it found is not evidence, and the value
|
|
7
|
+
* an investigation eventually joins on is the one the API read out of the text
|
|
8
|
+
* it stored. This copy exists to answer one question, locally and instantly:
|
|
9
|
+
*
|
|
10
|
+
* does what this person has already typed contain an identifier, or do we
|
|
11
|
+
* have to interrupt them for one?
|
|
12
|
+
*
|
|
13
|
+
* Getting that wrong in the lenient direction costs an unnecessary question.
|
|
14
|
+
* Getting it wrong in the strict direction means asking a reporter who has
|
|
15
|
+
* already told us, in the sentence above the box, which is the fastest way to
|
|
16
|
+
* make somebody close a bug reporter and not come back. So the matching rule
|
|
17
|
+
* here is the same rule, run over the same patterns, which the API ships with
|
|
18
|
+
* the field list precisely so that both sides can agree.
|
|
19
|
+
*
|
|
20
|
+
* The patterns arrive over the wire and are compiled here. That is a thing to
|
|
21
|
+
* be deliberate about rather than nervous about: they are shapes, not secrets —
|
|
22
|
+
* "a loan number looks like LN- followed by digits" is not a fact worth hiding
|
|
23
|
+
* from somebody already looking at their own loan number — and the alternative
|
|
24
|
+
* is a round trip per keystroke or a question for everybody.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const MAX_SCAN_CHARS = 20000;
|
|
28
|
+
const MAX_VALUE_CHARS = 120;
|
|
29
|
+
const MAX_MATCHES = 200;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compile, or null.
|
|
33
|
+
*
|
|
34
|
+
* Null on anything that will not compile, which in a browser is a real case
|
|
35
|
+
* rather than a defensive one: a regex feature one engine lacks throws here
|
|
36
|
+
* rather than failing to match. A field that cannot be compiled is simply not
|
|
37
|
+
* scanned, the reporter gets asked, and the server — which compiled it fine —
|
|
38
|
+
* still reads the text. Degrading to the question is the whole design.
|
|
39
|
+
*/
|
|
40
|
+
function compile(source) {
|
|
41
|
+
if (typeof source !== 'string' || !source.trim() || source.length > 400) return null;
|
|
42
|
+
try {
|
|
43
|
+
return new RegExp(source.trim(), 'gi');
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tidy = (value) => String(value).replace(/\s+/g, ' ').trim().slice(0, MAX_VALUE_CHARS);
|
|
50
|
+
|
|
51
|
+
/** Group 1 where the pattern has one, the whole match otherwise. */
|
|
52
|
+
function valueOf(match) {
|
|
53
|
+
const raw = match.length > 1 && match[1] !== undefined ? match[1] : match[0];
|
|
54
|
+
if (typeof raw !== 'string' || !raw.trim()) return null;
|
|
55
|
+
// The span claimed is the whole match, never the capture: a labelled rule has
|
|
56
|
+
// eaten the words in front of the value too, and leaving them unclaimed lets
|
|
57
|
+
// a later field match them again.
|
|
58
|
+
return { value: tidy(raw), start: match.index, end: match.index + match[0].length };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every configured identifier found in the given text.
|
|
63
|
+
*
|
|
64
|
+
* Fields are taken in the order the project arranged them and a span an earlier
|
|
65
|
+
* field claimed is not offered to a later one — which is what keeps the rule for
|
|
66
|
+
* "alternate number" and the rule for "mobile number", which match the same ten
|
|
67
|
+
* digits, from both claiming it.
|
|
68
|
+
*/
|
|
69
|
+
export function matchReferences(fields, texts) {
|
|
70
|
+
const found = [];
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
|
|
73
|
+
for (const block of texts || []) {
|
|
74
|
+
const text = typeof block === 'string' ? block.slice(0, MAX_SCAN_CHARS) : '';
|
|
75
|
+
if (!text) continue;
|
|
76
|
+
|
|
77
|
+
const claimed = [];
|
|
78
|
+
const overlaps = (a, b) => claimed.some(([s, e]) => a < e && b > s);
|
|
79
|
+
|
|
80
|
+
for (const field of fields || []) {
|
|
81
|
+
if (!field || field.scan === false) continue;
|
|
82
|
+
const re = compile(field.pattern);
|
|
83
|
+
if (!re) continue;
|
|
84
|
+
|
|
85
|
+
let match;
|
|
86
|
+
let guard = 0;
|
|
87
|
+
re.lastIndex = 0;
|
|
88
|
+
while ((match = re.exec(text)) !== null) {
|
|
89
|
+
if (++guard > MAX_MATCHES) break;
|
|
90
|
+
// exec does not advance past a zero-width match, so this would spin.
|
|
91
|
+
if (match[0] === '') { re.lastIndex += 1; continue; }
|
|
92
|
+
|
|
93
|
+
const hit = valueOf(match);
|
|
94
|
+
if (hit && !overlaps(hit.start, hit.end)) {
|
|
95
|
+
claimed.push([hit.start, hit.end]);
|
|
96
|
+
const dedupe = `${field.key}:${hit.value.toLowerCase()}`;
|
|
97
|
+
if (!seen.has(dedupe)) {
|
|
98
|
+
seen.add(dedupe);
|
|
99
|
+
found.push({ key: field.key, label: field.label, value: hit.value });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return found;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether what somebody typed into the box is the shape that field asks for.
|
|
111
|
+
*
|
|
112
|
+
* Anchored, unlike the scan above: reading prose means finding a value inside a
|
|
113
|
+
* sentence, and checking an answer means the answer *is* the value. `valuePattern`
|
|
114
|
+
* where the field has one — a rule that finds "alternate number: 98…" in prose
|
|
115
|
+
* needs the words, and a box already headed "Alternate number" will not have
|
|
116
|
+
* them typed into it again.
|
|
117
|
+
*
|
|
118
|
+
* Returns the tidied value, or null. Advisory only: the widget uses it to say
|
|
119
|
+
* "that does not look like a loan number" before sending, and the API keeps a
|
|
120
|
+
* value that fails it rather than dropping what the reporter believes is their
|
|
121
|
+
* reference.
|
|
122
|
+
*/
|
|
123
|
+
export function validateReference(field, raw) {
|
|
124
|
+
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
125
|
+
if (!value || value.length > MAX_VALUE_CHARS) return null;
|
|
126
|
+
|
|
127
|
+
const re = compile(field && (field.valuePattern || field.pattern));
|
|
128
|
+
if (!re) return null;
|
|
129
|
+
|
|
130
|
+
let match;
|
|
131
|
+
let guard = 0;
|
|
132
|
+
re.lastIndex = 0;
|
|
133
|
+
while ((match = re.exec(value)) !== null) {
|
|
134
|
+
if (++guard > MAX_MATCHES) break;
|
|
135
|
+
if (match[0] === '') { re.lastIndex += 1; continue; }
|
|
136
|
+
// One character of slack at the front and only there, for a pattern whose
|
|
137
|
+
// left boundary consumes rather than looks behind — see the phone rule,
|
|
138
|
+
// written that way so it compiles in older Safari.
|
|
139
|
+
const toEnd = match.index + match[0].length === value.length;
|
|
140
|
+
const atEdge = match.index === 0 || match.index === 1;
|
|
141
|
+
if (toEnd && atEdge) {
|
|
142
|
+
const hit = valueOf(match);
|
|
143
|
+
if (hit) return hit.value;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What this project needs in order to look a report up.
|
|
151
|
+
*
|
|
152
|
+
* Every failure answers `{ policy: 'off', fields: [] }`, which is the answer
|
|
153
|
+
* that draws nothing. A widget that could not reach this route must not be a
|
|
154
|
+
* widget that refuses to send a report — the whole feature is a help, and the
|
|
155
|
+
* server reads the text either way.
|
|
156
|
+
*/
|
|
157
|
+
export async function referenceFields({ apiBase, apiKey, projectId }) {
|
|
158
|
+
const off = { policy: 'off', fields: [] };
|
|
159
|
+
try {
|
|
160
|
+
const res = await fetch(
|
|
161
|
+
`${apiBase}/ingest/reference-fields?projectId=${encodeURIComponent(projectId)}`,
|
|
162
|
+
{ headers: { 'X-BugCatch-Key': apiKey } },
|
|
163
|
+
);
|
|
164
|
+
if (!res.ok) return off;
|
|
165
|
+
const body = await res.json();
|
|
166
|
+
const policy = body.policy === 'optional' || body.policy === 'required' ? body.policy : 'off';
|
|
167
|
+
const fields = Array.isArray(body.fields) ? body.fields : [];
|
|
168
|
+
return fields.length ? { policy, fields } : off;
|
|
169
|
+
} catch {
|
|
170
|
+
return off;
|
|
171
|
+
}
|
|
172
|
+
}
|