@awesomate/sdk 0.10.0 → 0.12.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 +25 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.js +94 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,6 +30,18 @@ const one = await db.get('contact', rows[0].id); // null if there is none
|
|
|
30
30
|
const byPlan = await db.aggregate({ measures: [{ column: 'people', agg: 'count' }], dimensions: ['plan'] });
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
## The business itself
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
const biz = await db.business();
|
|
37
|
+
biz.name; // 'Acme Plumbing'
|
|
38
|
+
for (const g of biz.groups) for (const f of g.facts) console.log(g.title, f.label, f.value, f.status, f.source);
|
|
39
|
+
biz.completeness; // { core_set: 7, core_total: 8, missing_core: ['logo'] }
|
|
40
|
+
const waiting = await db.businessSuggestions(); // details waiting for the owner's yes
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The details Awesomate keeps about the business: name, what it does, voice, colours, how customers reach it. Each one says where it came from. `confirmed` details are the ones agents and automations use. A `suggestion` waits for the owner, who confirms it on Knowledge, Your business in the hub. This needs the account's token, on every plan, never an app key.
|
|
44
|
+
|
|
33
45
|
## Writing app data (Support Plus and above)
|
|
34
46
|
|
|
35
47
|
Define the tables an app keeps, then write to them. There's no migration and no SQL; every value is
|
|
@@ -151,6 +163,19 @@ If the account switched on reply emails, someone looking at the app isn't emaile
|
|
|
151
163
|
but a tab left open in the background doesn't count as looking, and closing the last one counts as
|
|
152
164
|
leaving straight away.
|
|
153
165
|
|
|
166
|
+
A conversation can also show who else has it open and who is typing:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
const room = app.here(job.id, (people) => showWhoIsHere(people)); // [{ user, role, name, typing, assistant? }]
|
|
170
|
+
messageBox.oninput = () => room.typing(true); // sent at most every few seconds
|
|
171
|
+
messageBox.onblur = () => room.typing(false);
|
|
172
|
+
// later: room.leave();
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Everyone else with that record open and their tab in front is listed once, and the app's
|
|
176
|
+
assistant appears while it writes a reply. The hub checks this person may read the record, and
|
|
177
|
+
keeps checking; a refusal goes to the optional third argument, `onError`. Nothing is stored.
|
|
178
|
+
|
|
154
179
|
### Staff find a customer
|
|
155
180
|
|
|
156
181
|
People in an app read no contacts but their own, so by default staff cannot put a job under a
|
package/dist/index.d.ts
CHANGED
|
@@ -224,6 +224,15 @@ export declare class AwesomateClient {
|
|
|
224
224
|
* Never retried: a write that may have landed is not safe to send twice.
|
|
225
225
|
*/
|
|
226
226
|
call<R extends RecipeName>(recipe: R, args?: ArgsOf<R>): Promise<RecipeResult>;
|
|
227
|
+
/**
|
|
228
|
+
* The business itself: the details Awesomate keeps about it (name, what it does, voice, colours,
|
|
229
|
+
* how customers reach it), grouped, each with where it came from. `confirmed` details are the
|
|
230
|
+
* ones agents and automations use; `suggestion` ones wait for the owner. Needs the account's
|
|
231
|
+
* token (hosting:read, every plan), never an app key.
|
|
232
|
+
*/
|
|
233
|
+
business(): Promise<BusinessIdentity>;
|
|
234
|
+
/** Details waiting for the owner's yes on Your business: from the website, the account, a team member or a file. */
|
|
235
|
+
businessSuggestions(): Promise<BusinessSuggestion[]>;
|
|
227
236
|
/** Grouped numbers (counts, sums) in the Business Data API's shape. */
|
|
228
237
|
aggregate(request: {
|
|
229
238
|
measures: Array<{
|
|
@@ -251,6 +260,43 @@ export declare class AwesomateClient {
|
|
|
251
260
|
limit?: number;
|
|
252
261
|
}): Promise<Record<string, unknown>>;
|
|
253
262
|
}
|
|
263
|
+
/** One detail about the business. */
|
|
264
|
+
export interface BusinessFact {
|
|
265
|
+
key: string;
|
|
266
|
+
label: string;
|
|
267
|
+
value: string;
|
|
268
|
+
/** confirmed: in use by agents and automations. suggestion: waiting for the owner. */
|
|
269
|
+
status: 'confirmed' | 'suggestion';
|
|
270
|
+
/** Where it came from, in words: "confirmed by the owner", "added by a team member", "website research"... */
|
|
271
|
+
source: string;
|
|
272
|
+
}
|
|
273
|
+
export interface BusinessIdentity {
|
|
274
|
+
account: string;
|
|
275
|
+
name: string | null;
|
|
276
|
+
groups: Array<{
|
|
277
|
+
id: string;
|
|
278
|
+
title: string;
|
|
279
|
+
facts: BusinessFact[];
|
|
280
|
+
}>;
|
|
281
|
+
/** The eight core details every business should have, and which are missing. */
|
|
282
|
+
completeness: {
|
|
283
|
+
core_set: number;
|
|
284
|
+
core_total: number;
|
|
285
|
+
missing_core: string[];
|
|
286
|
+
};
|
|
287
|
+
/** Whether each source could be read: 'read', 'empty' or 'unavailable'. */
|
|
288
|
+
sources: Record<'confirmed_details' | 'saved_details' | 'account_details' | 'website_research', string>;
|
|
289
|
+
how_to_change: string;
|
|
290
|
+
}
|
|
291
|
+
export interface BusinessSuggestion {
|
|
292
|
+
id: number;
|
|
293
|
+
key: string;
|
|
294
|
+
value: string;
|
|
295
|
+
/** website, research, import, team_member, staff, upload, legacy_variables... */
|
|
296
|
+
sourceKind: string;
|
|
297
|
+
sourceRef: string | null;
|
|
298
|
+
recordedAt: string;
|
|
299
|
+
}
|
|
254
300
|
export interface AttributeSpec {
|
|
255
301
|
key: string;
|
|
256
302
|
label?: string;
|
|
@@ -418,6 +464,26 @@ export interface LiveChange<R> {
|
|
|
418
464
|
/** Ids that left the list. */
|
|
419
465
|
removes: string[];
|
|
420
466
|
}
|
|
467
|
+
/** Someone else with the same record open (app.here), as the hub sends them. */
|
|
468
|
+
export interface HerePerson {
|
|
469
|
+
/** Their app user id, or 'assistant' for the app's AI assistant. */
|
|
470
|
+
user: string;
|
|
471
|
+
role: string;
|
|
472
|
+
/** Their first name from the account's Contacts, or null. */
|
|
473
|
+
name: string | null;
|
|
474
|
+
typing: boolean;
|
|
475
|
+
/** The app's AI assistant, writing a reply. */
|
|
476
|
+
assistant?: true;
|
|
477
|
+
}
|
|
478
|
+
export interface HereHandle {
|
|
479
|
+
/**
|
|
480
|
+
* Say this person is typing (call it on each keystroke: it sends at most every few seconds) or has
|
|
481
|
+
* stopped (on send or blur). Without another call, typing ends on its own after a few seconds.
|
|
482
|
+
*/
|
|
483
|
+
typing(on: boolean): void;
|
|
484
|
+
/** Close the record: the others stop seeing this person on it. */
|
|
485
|
+
leave(): void;
|
|
486
|
+
}
|
|
421
487
|
export interface LiveHandlers<R> {
|
|
422
488
|
/** Called with the whole list, in the query's order, at first and after every change. */
|
|
423
489
|
onRows: (rows: R[], change: LiveChange<R> | null) => void;
|
|
@@ -516,6 +582,15 @@ export declare class AwesomateAppClient {
|
|
|
516
582
|
* a fresh snapshot when it does. Returns stop().
|
|
517
583
|
*/
|
|
518
584
|
live<K extends KindName, S extends keyof RowOf<K> = keyof RowOf<K>>(kind: K, options: Omit<QueryOptions<RowOf<K>, S>, 'after'>, handlers: LiveHandlers<Pick<RowOf<K>, S>> | ((rows: Array<Pick<RowOf<K>, S>>, change: LiveChange<Pick<RowOf<K>, S>> | null) => void)): () => void;
|
|
585
|
+
/** The one live connection behind live() and here(), made on first use. */
|
|
586
|
+
private liveSocket;
|
|
587
|
+
/**
|
|
588
|
+
* Open a record (a job's conversation) for who-is-here: onPeople gets everyone else who has it
|
|
589
|
+
* open with their tab in front, and who is typing, at first and on every change; the app's
|
|
590
|
+
* assistant appears while it writes a reply. The hub checks this person may read the record, and
|
|
591
|
+
* keeps checking; onError hears a refusal. Shares the one live connection. Returns typing() and leave().
|
|
592
|
+
*/
|
|
593
|
+
here(recordId: string, onPeople: (people: HerePerson[]) => void, onError?: (err: AwesomateError) => void): HereHandle;
|
|
519
594
|
}
|
|
520
595
|
export declare function createAppClient(options: AppClientOptions): AwesomateAppClient;
|
|
521
596
|
export declare function createClient(options: ClientOptions): AwesomateClient;
|
package/dist/index.js
CHANGED
|
@@ -215,6 +215,19 @@ export class AwesomateClient {
|
|
|
215
215
|
async call(recipe, args = {}) {
|
|
216
216
|
return (await this.request('POST', '/api/my-crm/v1/call', { fn: recipe, args }, false)).result;
|
|
217
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* The business itself: the details Awesomate keeps about it (name, what it does, voice, colours,
|
|
220
|
+
* how customers reach it), grouped, each with where it came from. `confirmed` details are the
|
|
221
|
+
* ones agents and automations use; `suggestion` ones wait for the owner. Needs the account's
|
|
222
|
+
* token (hosting:read, every plan), never an app key.
|
|
223
|
+
*/
|
|
224
|
+
business() {
|
|
225
|
+
return this.request('GET', '/api/my-business/v1/identity?format=json');
|
|
226
|
+
}
|
|
227
|
+
/** Details waiting for the owner's yes on Your business: from the website, the account, a team member or a file. */
|
|
228
|
+
async businessSuggestions() {
|
|
229
|
+
return (await this.request('GET', '/api/my-business/v1/proposals')).proposals;
|
|
230
|
+
}
|
|
218
231
|
/** Grouped numbers (counts, sums) in the Business Data API's shape. */
|
|
219
232
|
aggregate(request) {
|
|
220
233
|
return this.request('POST', '/api/my-crm/v1/data/query', request);
|
|
@@ -539,15 +552,29 @@ export class AwesomateAppClient {
|
|
|
539
552
|
*/
|
|
540
553
|
live(kind, options, handlers) {
|
|
541
554
|
const h = typeof handlers === 'function' ? { onRows: handlers } : handlers;
|
|
542
|
-
this.
|
|
555
|
+
return this.liveSocket().add({ kind, ...options }, h);
|
|
556
|
+
}
|
|
557
|
+
/** The one live connection behind live() and here(), made on first use. */
|
|
558
|
+
liveSocket() {
|
|
559
|
+
return this.socket ??= new LiveSocket({
|
|
543
560
|
url: `${this.base.replace(/^http/, 'ws')}/api/sdk/v1/socket`,
|
|
544
561
|
WebSocket: this.opts.WebSocket ?? globalThis.WebSocket,
|
|
545
562
|
session: () => this.current(),
|
|
546
563
|
refresh: async () => { const s = await this.load(); return s ? this.refresh(s) : null; },
|
|
547
564
|
});
|
|
548
|
-
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Open a record (a job's conversation) for who-is-here: onPeople gets everyone else who has it
|
|
568
|
+
* open with their tab in front, and who is typing, at first and on every change; the app's
|
|
569
|
+
* assistant appears while it writes a reply. The hub checks this person may read the record, and
|
|
570
|
+
* keeps checking; onError hears a refusal. Shares the one live connection. Returns typing() and leave().
|
|
571
|
+
*/
|
|
572
|
+
here(recordId, onPeople, onError) {
|
|
573
|
+
return this.liveSocket().join(recordId, onPeople, onError);
|
|
549
574
|
}
|
|
550
575
|
}
|
|
576
|
+
/** A page saying "still typing" more often than this is not sent: the hub keeps it for six seconds. */
|
|
577
|
+
const TYPING_RESEND_MS = 2_500;
|
|
551
578
|
/** Timers must never keep a Node process (a test, a server-side render) alive; browsers have no unref. */
|
|
552
579
|
function unref(t) {
|
|
553
580
|
t.unref?.();
|
|
@@ -583,6 +610,41 @@ class LiveSocket {
|
|
|
583
610
|
if (this.ready && typeof d?.visibilityState === 'string')
|
|
584
611
|
this.send({ type: 'presence', visible: d.visibilityState === 'visible' });
|
|
585
612
|
}
|
|
613
|
+
/** Records open with here(): who else is on them goes to onPeople. */
|
|
614
|
+
rooms = new Map();
|
|
615
|
+
get wanted() {
|
|
616
|
+
return this.entries.size > 0 || this.rooms.size > 0;
|
|
617
|
+
}
|
|
618
|
+
join(id, onPeople, onError) {
|
|
619
|
+
const room = { onPeople, onError, typingSentAt: 0 };
|
|
620
|
+
this.rooms.set(id, room);
|
|
621
|
+
if (this.ready)
|
|
622
|
+
this.send({ type: 'here', id, on: true });
|
|
623
|
+
else if (!this.ws && !this.connecting)
|
|
624
|
+
void this.connect();
|
|
625
|
+
return {
|
|
626
|
+
typing: (on) => {
|
|
627
|
+
if (this.rooms.get(id) !== room || !this.ready)
|
|
628
|
+
return;
|
|
629
|
+
const now = Date.now();
|
|
630
|
+
if (on && now - room.typingSentAt < TYPING_RESEND_MS)
|
|
631
|
+
return;
|
|
632
|
+
if (!on && !room.typingSentAt)
|
|
633
|
+
return;
|
|
634
|
+
room.typingSentAt = on ? now : 0;
|
|
635
|
+
this.send({ type: 'typing', id, on });
|
|
636
|
+
},
|
|
637
|
+
leave: () => {
|
|
638
|
+
if (this.rooms.get(id) !== room)
|
|
639
|
+
return;
|
|
640
|
+
this.rooms.delete(id);
|
|
641
|
+
if (this.ready)
|
|
642
|
+
this.send({ type: 'here', id, on: false });
|
|
643
|
+
if (!this.wanted)
|
|
644
|
+
this.shut();
|
|
645
|
+
},
|
|
646
|
+
};
|
|
647
|
+
}
|
|
586
648
|
add(spec, handlers) {
|
|
587
649
|
const id = `l${++this.seq}`;
|
|
588
650
|
this.entries.set(id, { spec, handlers, rows: new Map(), order: [] });
|
|
@@ -595,7 +657,7 @@ class LiveSocket {
|
|
|
595
657
|
return;
|
|
596
658
|
if (this.ready)
|
|
597
659
|
this.send({ type: 'unsub', id });
|
|
598
|
-
if (!this.
|
|
660
|
+
if (!this.wanted)
|
|
599
661
|
this.shut();
|
|
600
662
|
};
|
|
601
663
|
}
|
|
@@ -604,7 +666,7 @@ class LiveSocket {
|
|
|
604
666
|
this.ws.send(JSON.stringify(f));
|
|
605
667
|
}
|
|
606
668
|
async connect() {
|
|
607
|
-
if (this.stopped || this.ws || this.connecting || !this.
|
|
669
|
+
if (this.stopped || this.ws || this.connecting || !this.wanted)
|
|
608
670
|
return;
|
|
609
671
|
if (!this.o.WebSocket) {
|
|
610
672
|
this.failAll(new AwesomateError('unavailable', 'No WebSocket here: pass createAppClient({ WebSocket }).', 0));
|
|
@@ -622,7 +684,7 @@ class LiveSocket {
|
|
|
622
684
|
this.failAll(new AwesomateError('unauthenticated', 'Sign in first.', 401));
|
|
623
685
|
return;
|
|
624
686
|
}
|
|
625
|
-
if (this.stopped || this.ws || !this.
|
|
687
|
+
if (this.stopped || this.ws || !this.wanted)
|
|
626
688
|
return;
|
|
627
689
|
this.scheduleRefresh(session);
|
|
628
690
|
const ws = new this.o.WebSocket(this.o.url);
|
|
@@ -646,9 +708,29 @@ class LiveSocket {
|
|
|
646
708
|
for (const [id, e] of this.entries)
|
|
647
709
|
this.send({ type: 'sub', id, spec: e.spec });
|
|
648
710
|
this.sendPresence();
|
|
711
|
+
for (const [id, room] of this.rooms) {
|
|
712
|
+
room.typingSentAt = 0;
|
|
713
|
+
this.send({ type: 'here', id, on: true });
|
|
714
|
+
}
|
|
649
715
|
this.pinger = unref(setInterval(() => this.send({ type: 'ping' }), 25_000));
|
|
650
716
|
return;
|
|
651
717
|
}
|
|
718
|
+
if (f.type === 'people' && typeof f.id === 'string') {
|
|
719
|
+
this.rooms.get(f.id)?.onPeople(Array.isArray(f.people) ? f.people : []);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (f.type === 'error' && typeof f.id === 'string' && f.id.startsWith('here:')) {
|
|
723
|
+
const id = f.id.slice(5);
|
|
724
|
+
const room = this.rooms.get(id);
|
|
725
|
+
if (!room)
|
|
726
|
+
return;
|
|
727
|
+
this.rooms.delete(id);
|
|
728
|
+
const code = ERROR_CODES.includes(String(f.code)) ? f.code : 'unavailable';
|
|
729
|
+
room.onError?.(new AwesomateError(code, String(f.message ?? 'Refused.'), 0, undefined, String(f.code)));
|
|
730
|
+
if (!this.wanted)
|
|
731
|
+
this.shut();
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
652
734
|
const e = typeof f.id === 'string' ? this.entries.get(f.id) : undefined;
|
|
653
735
|
if (f.type === 'error') {
|
|
654
736
|
const code = ERROR_CODES.includes(String(f.code)) ? f.code : 'unavailable';
|
|
@@ -713,7 +795,8 @@ class LiveSocket {
|
|
|
713
795
|
if (this.refresher)
|
|
714
796
|
clearTimeout(this.refresher);
|
|
715
797
|
const ms = Math.max(5_000, s.expires_at - Date.now() - 60_000);
|
|
716
|
-
|
|
798
|
+
// A socket kept open only by here() needs a fresh token too, or the hub closes it at expiry.
|
|
799
|
+
this.refresher = unref(setTimeout(() => { if (this.wanted)
|
|
717
800
|
void this.o.refresh(); }, ms));
|
|
718
801
|
}
|
|
719
802
|
async closed(ws, code) {
|
|
@@ -724,7 +807,7 @@ class LiveSocket {
|
|
|
724
807
|
if (this.pinger)
|
|
725
808
|
clearInterval(this.pinger);
|
|
726
809
|
this.pinger = null;
|
|
727
|
-
if (this.stopped || !this.
|
|
810
|
+
if (this.stopped || !this.wanted)
|
|
728
811
|
return;
|
|
729
812
|
if (code === 4401) {
|
|
730
813
|
// Refused as unauthenticated: one refresh; if that is refused too, the person is signed out.
|
|
@@ -739,9 +822,13 @@ class LiveSocket {
|
|
|
739
822
|
}
|
|
740
823
|
failAll(err) {
|
|
741
824
|
const all = [...this.entries.values()];
|
|
825
|
+
const rooms = [...this.rooms.values()];
|
|
742
826
|
this.entries.clear();
|
|
827
|
+
this.rooms.clear();
|
|
743
828
|
for (const e of all)
|
|
744
829
|
e.handlers.onError?.(err);
|
|
830
|
+
for (const r of rooms)
|
|
831
|
+
r.onError?.(err);
|
|
745
832
|
this.shut();
|
|
746
833
|
}
|
|
747
834
|
shut() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awesomate/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Your own Awesomate data from Node and the browser: query contacts and app data with generated types, and sign your app's own users in",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|