@nistrix/chat 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +71 -2
- package/dist/chat.d.ts +71 -0
- package/dist/chat.js +212 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/intents.d.ts +110 -0
- package/dist/intents.js +91 -0
- package/package.json +34 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nistrix Ltda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,72 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nistrix/chat
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Nistrix chat for your own site or app: your visitors talk to your Nistrix agents and team, in a screen you design. It runs in browsers and in React Native, with no other dependency.
|
|
4
|
+
|
|
5
|
+
It uses a **publishable key** (`nx_pk_live_...`, or `nx_pk_test_...` in your test environment). It is not a secret: it can only open visitor chats on one chat channel, from the sites and apps you listed on it. Never put a secret key (`nx_sk_`, `nx_rk_`) in a site or app.
|
|
6
|
+
|
|
7
|
+
## Get a key
|
|
8
|
+
|
|
9
|
+
In Nistrix, **Integrations → API and MCP → Nistrix in your apps → Publishable keys**: choose the chat channel, list your sites (`https://shop.example`) and apps (`com.shop.app`), and copy the key.
|
|
10
|
+
|
|
11
|
+
## Use it
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { NistrixChat } from "@nistrix/chat";
|
|
15
|
+
|
|
16
|
+
const chat = new NistrixChat({
|
|
17
|
+
publishableKey: "nx_pk_live_...",
|
|
18
|
+
storage: window.localStorage, // keeps the visitor between visits
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const history = await chat.messages();
|
|
22
|
+
await chat.send("Hi, can I book a time?", { name: "Bia" });
|
|
23
|
+
|
|
24
|
+
const stop = chat.subscribe((messages) => render(messages)); // the agent or team wrote
|
|
25
|
+
// later: stop();
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### React Native
|
|
29
|
+
|
|
30
|
+
Native apps have no origin, so they name themselves with `appId` (the bundle id or package name listed on the key):
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
34
|
+
|
|
35
|
+
const chat = new NistrixChat({
|
|
36
|
+
publishableKey: "nx_pk_live_...",
|
|
37
|
+
appId: "com.shop.app",
|
|
38
|
+
storage: AsyncStorage,
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Errors
|
|
43
|
+
|
|
44
|
+
Calls throw `ChatError` with the HTTP `status` and the API's `code`, for example `origin-not-allowed` (add the site or app to the key) or `rate-limited`.
|
|
45
|
+
|
|
46
|
+
## Booking pages and lead forms
|
|
47
|
+
|
|
48
|
+
`NistrixIntents` uses the same key to show free times, ask for appointments, send leads, and turn sensitive values into data tokens. A booking or a lead is an intent (`nx_int_`, ten minutes) that your backend confirms with a secret key, unless the key confirms it on its own.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { NistrixIntents } from "@nistrix/chat";
|
|
52
|
+
|
|
53
|
+
const nistrix = new NistrixIntents({ publishableKey: "nx_pk_live_..." });
|
|
54
|
+
|
|
55
|
+
const [slot] = await nistrix.slots({ service: "Cleaning", from: "2026-12-08" });
|
|
56
|
+
const booking = await nistrix.book({ start: slot.localStart, name: "Bia", phone: "92991234567" });
|
|
57
|
+
|
|
58
|
+
const cpf = await nistrix.tokenize("cpf", "529.982.247-25"); // nx_tok_..., never the value
|
|
59
|
+
const lead = await nistrix.submitLead({ phone: "92991234567", fields: { cpf: cpf.token } });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Test environment
|
|
63
|
+
|
|
64
|
+
A `nx_pk_test_` key from your sandbox works the same way; conversations, bookings, and leads land in the sandbox, and `test` is `true`.
|
|
65
|
+
|
|
66
|
+
## Develop
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pnpm install
|
|
70
|
+
pnpm test
|
|
71
|
+
pnpm build
|
|
72
|
+
```
|
package/dist/chat.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Nistrix chat for customers' sites and apps. A publishable key (nx_pk_) opens visitor
|
|
3
|
+
* chats on one of the organization's chat channels, from the sites and apps listed on the key:
|
|
4
|
+
* browsers are recognized by their origin, native apps by `appId` (their bundle id or package
|
|
5
|
+
* name). The key is not a secret and can do nothing else.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_API_URL = "https://api.nistrix.com.br";
|
|
8
|
+
export interface ChatAttachment {
|
|
9
|
+
/** Opens the file for a few minutes; a new link comes with every refresh. */
|
|
10
|
+
url: string;
|
|
11
|
+
fileName: string;
|
|
12
|
+
mimeType: string;
|
|
13
|
+
size: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ChatMessage {
|
|
16
|
+
id: string;
|
|
17
|
+
/** INBOUND: written by the visitor; OUTBOUND: by the agent or the team. */
|
|
18
|
+
direction: "INBOUND" | "OUTBOUND";
|
|
19
|
+
kind: string;
|
|
20
|
+
body: string | null;
|
|
21
|
+
status: string;
|
|
22
|
+
sentAt: string;
|
|
23
|
+
attachment?: ChatAttachment | null;
|
|
24
|
+
}
|
|
25
|
+
/** Where the visitor's session is kept between visits (localStorage, AsyncStorage, ...). */
|
|
26
|
+
export interface VisitorStorage {
|
|
27
|
+
getItem(key: string): string | null | Promise<string | null>;
|
|
28
|
+
setItem(key: string, value: string): void | Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export interface NistrixChatOptions {
|
|
31
|
+
/** The organization's publishable key: nx_pk_live_... or, in its sandbox, nx_pk_test_.... */
|
|
32
|
+
publishableKey: string;
|
|
33
|
+
apiUrl?: string;
|
|
34
|
+
/** Native apps only: the bundle id or package name listed on the key. */
|
|
35
|
+
appId?: string;
|
|
36
|
+
/** Keeps the visitor's session; in memory when left out (a new visitor every launch). */
|
|
37
|
+
storage?: VisitorStorage;
|
|
38
|
+
fetch?: typeof fetch;
|
|
39
|
+
/** The WebSocket class; React Native's accepts headers, which native apps need. */
|
|
40
|
+
WebSocket?: WebSocketFactory;
|
|
41
|
+
}
|
|
42
|
+
export type WebSocketFactory = new (url: string, protocols?: string | string[], options?: {
|
|
43
|
+
headers?: Record<string, string>;
|
|
44
|
+
}) => WebSocket;
|
|
45
|
+
export declare class ChatError extends Error {
|
|
46
|
+
readonly status: number;
|
|
47
|
+
readonly code: string | null;
|
|
48
|
+
constructor(status: number, code: string | null);
|
|
49
|
+
}
|
|
50
|
+
export declare class NistrixChat {
|
|
51
|
+
#private;
|
|
52
|
+
constructor(options: NistrixChatOptions);
|
|
53
|
+
/** True for a sandbox key: nothing here reaches real customers. */
|
|
54
|
+
get test(): boolean;
|
|
55
|
+
/** The chat's name and greeting, and whether the organization is available. */
|
|
56
|
+
config(): Promise<{
|
|
57
|
+
name: string;
|
|
58
|
+
greeting: string | null;
|
|
59
|
+
available: boolean;
|
|
60
|
+
}>;
|
|
61
|
+
/** The visitor's conversation so far, oldest first. */
|
|
62
|
+
messages(): Promise<ChatMessage[]>;
|
|
63
|
+
/** Sends what the visitor wrote; `name` tells the team who is writing. Returns the chat. */
|
|
64
|
+
send(body: string, options?: {
|
|
65
|
+
name?: string;
|
|
66
|
+
}): Promise<ChatMessage[]>;
|
|
67
|
+
/** Calls `listener` with the whole chat whenever the agent or the team writes. */
|
|
68
|
+
subscribe(listener: (messages: ChatMessage[]) => void): () => void;
|
|
69
|
+
/** Stops listening; the visitor's session stays for the next visit. */
|
|
70
|
+
close(): void;
|
|
71
|
+
}
|
package/dist/chat.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Nistrix chat for customers' sites and apps. A publishable key (nx_pk_) opens visitor
|
|
3
|
+
* chats on one of the organization's chat channels, from the sites and apps listed on the key:
|
|
4
|
+
* browsers are recognized by their origin, native apps by `appId` (their bundle id or package
|
|
5
|
+
* name). The key is not a secret and can do nothing else.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_API_URL = "https://api.nistrix.com.br";
|
|
8
|
+
const APP_HEADER = "X-Nistrix-App";
|
|
9
|
+
const FIRST_RETRY_MS = 1000;
|
|
10
|
+
const MAX_RETRY_MS = 30_000;
|
|
11
|
+
export class ChatError extends Error {
|
|
12
|
+
status;
|
|
13
|
+
code;
|
|
14
|
+
constructor(status, code) {
|
|
15
|
+
super(`Nistrix chat request failed (${String(status)}${code ? `, ${code}` : ""}).`);
|
|
16
|
+
this.name = "ChatError";
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.code = code;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
class MemoryStorage {
|
|
22
|
+
#items = new Map();
|
|
23
|
+
getItem(key) {
|
|
24
|
+
return this.#items.get(key) ?? null;
|
|
25
|
+
}
|
|
26
|
+
setItem(key, value) {
|
|
27
|
+
this.#items.set(key, value);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function newId() {
|
|
31
|
+
return globalThis.crypto.randomUUID();
|
|
32
|
+
}
|
|
33
|
+
export class NistrixChat {
|
|
34
|
+
#base;
|
|
35
|
+
#apiUrl;
|
|
36
|
+
#key;
|
|
37
|
+
#appId;
|
|
38
|
+
#storage;
|
|
39
|
+
#fetch;
|
|
40
|
+
#WebSocket;
|
|
41
|
+
#token = null;
|
|
42
|
+
#socket = null;
|
|
43
|
+
#listeners = new Set();
|
|
44
|
+
#retryMs = FIRST_RETRY_MS;
|
|
45
|
+
#closed = false;
|
|
46
|
+
constructor(options) {
|
|
47
|
+
if (!options.publishableKey.startsWith("nx_pk_")) {
|
|
48
|
+
throw new Error("Use a publishable key (nx_pk_...); never a secret key in an app.");
|
|
49
|
+
}
|
|
50
|
+
this.#apiUrl = (options.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
|
|
51
|
+
this.#key = options.publishableKey;
|
|
52
|
+
this.#base = `${this.#apiUrl}/widget/${encodeURIComponent(this.#key)}`;
|
|
53
|
+
this.#appId = options.appId;
|
|
54
|
+
this.#storage = options.storage ?? new MemoryStorage();
|
|
55
|
+
this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
56
|
+
this.#WebSocket = options.WebSocket ?? globalThis.WebSocket;
|
|
57
|
+
}
|
|
58
|
+
/** True for a sandbox key: nothing here reaches real customers. */
|
|
59
|
+
get test() {
|
|
60
|
+
return this.#key.startsWith("nx_pk_test_");
|
|
61
|
+
}
|
|
62
|
+
get #storageKey() {
|
|
63
|
+
return `nistrix-chat:${this.#key}:visitor`;
|
|
64
|
+
}
|
|
65
|
+
get #headers() {
|
|
66
|
+
return this.#appId ? { [APP_HEADER]: this.#appId } : {};
|
|
67
|
+
}
|
|
68
|
+
async #request(path, init) {
|
|
69
|
+
const headers = { ...this.#headers, accept: "application/json" };
|
|
70
|
+
if (init.body !== undefined) {
|
|
71
|
+
headers["content-type"] = "application/json";
|
|
72
|
+
}
|
|
73
|
+
if (init.auth) {
|
|
74
|
+
headers.authorization = `Bearer ${await this.#session()}`;
|
|
75
|
+
}
|
|
76
|
+
const response = await this.#fetch(`${this.#base}${path}`, {
|
|
77
|
+
method: init.method ?? "GET",
|
|
78
|
+
headers,
|
|
79
|
+
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
80
|
+
});
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
let code = null;
|
|
83
|
+
try {
|
|
84
|
+
const problem = (await response.json());
|
|
85
|
+
code = problem.type?.split(":").pop() ?? null;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Not a problem document.
|
|
89
|
+
}
|
|
90
|
+
throw new ChatError(response.status, code);
|
|
91
|
+
}
|
|
92
|
+
return (await response.json());
|
|
93
|
+
}
|
|
94
|
+
async #session(forceNew = false) {
|
|
95
|
+
if (this.#token && !forceNew) {
|
|
96
|
+
return this.#token;
|
|
97
|
+
}
|
|
98
|
+
const stored = forceNew ? null : await this.#storage.getItem(this.#storageKey);
|
|
99
|
+
const { visitorToken } = await this.#request("/visitors", {
|
|
100
|
+
method: "POST",
|
|
101
|
+
body: { visitorToken: stored },
|
|
102
|
+
});
|
|
103
|
+
this.#token = visitorToken;
|
|
104
|
+
await this.#storage.setItem(this.#storageKey, visitorToken);
|
|
105
|
+
return visitorToken;
|
|
106
|
+
}
|
|
107
|
+
/** Runs a call as the visitor, starting a new session once if the old one expired. */
|
|
108
|
+
async #asVisitor(path, init = {}) {
|
|
109
|
+
try {
|
|
110
|
+
return await this.#request(path, { ...init, auth: true });
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (!(error instanceof ChatError) || error.status !== 401) {
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
await this.#session(true);
|
|
117
|
+
return this.#request(path, { ...init, auth: true });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** The chat's name and greeting, and whether the organization is available. */
|
|
121
|
+
async config() {
|
|
122
|
+
return this.#request("", {});
|
|
123
|
+
}
|
|
124
|
+
/** The visitor's conversation so far, oldest first. */
|
|
125
|
+
async messages() {
|
|
126
|
+
const { items } = await this.#asVisitor("/messages");
|
|
127
|
+
return items;
|
|
128
|
+
}
|
|
129
|
+
/** Sends what the visitor wrote; `name` tells the team who is writing. Returns the chat. */
|
|
130
|
+
async send(body, options = {}) {
|
|
131
|
+
const { items } = await this.#asVisitor("/messages", {
|
|
132
|
+
method: "POST",
|
|
133
|
+
body: { body, clientMessageId: newId(), name: options.name ?? null },
|
|
134
|
+
});
|
|
135
|
+
this.#emit(items);
|
|
136
|
+
return items;
|
|
137
|
+
}
|
|
138
|
+
/** Calls `listener` with the whole chat whenever the agent or the team writes. */
|
|
139
|
+
subscribe(listener) {
|
|
140
|
+
this.#listeners.add(listener);
|
|
141
|
+
this.#closed = false;
|
|
142
|
+
if (!this.#socket) {
|
|
143
|
+
void this.#connect();
|
|
144
|
+
}
|
|
145
|
+
return () => {
|
|
146
|
+
this.#listeners.delete(listener);
|
|
147
|
+
if (this.#listeners.size === 0) {
|
|
148
|
+
this.close();
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Stops listening; the visitor's session stays for the next visit. */
|
|
153
|
+
close() {
|
|
154
|
+
this.#closed = true;
|
|
155
|
+
this.#socket?.close();
|
|
156
|
+
this.#socket = null;
|
|
157
|
+
}
|
|
158
|
+
#emit(messages) {
|
|
159
|
+
for (const listener of this.#listeners) {
|
|
160
|
+
listener(messages);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async #connect() {
|
|
164
|
+
if (!this.#WebSocket || this.#closed) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const { ticket } = await this.#asVisitor("/tickets", { method: "POST" });
|
|
169
|
+
const url = new URL("/widget/realtime", this.#apiUrl);
|
|
170
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
171
|
+
url.searchParams.set("ticket", ticket);
|
|
172
|
+
url.searchParams.set("key", this.#key);
|
|
173
|
+
if (this.#appId) {
|
|
174
|
+
// Not every WebSocket can send headers (browsers', Node's): the app is named here too.
|
|
175
|
+
url.searchParams.set("app", this.#appId);
|
|
176
|
+
}
|
|
177
|
+
// Browsers send their origin themselves; native apps name themselves in a header.
|
|
178
|
+
const socket = this.#appId
|
|
179
|
+
? new this.#WebSocket(url.toString(), undefined, { headers: this.#headers })
|
|
180
|
+
: new this.#WebSocket(url.toString());
|
|
181
|
+
this.#socket = socket;
|
|
182
|
+
socket.onopen = () => {
|
|
183
|
+
this.#retryMs = FIRST_RETRY_MS;
|
|
184
|
+
};
|
|
185
|
+
socket.onmessage = (event) => {
|
|
186
|
+
const data = JSON.parse(String(event.data));
|
|
187
|
+
if (data.type !== "ping") {
|
|
188
|
+
void this.messages().then((items) => {
|
|
189
|
+
this.#emit(items);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
socket.onclose = () => {
|
|
194
|
+
this.#socket = null;
|
|
195
|
+
this.#reconnect();
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
this.#reconnect();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
#reconnect() {
|
|
203
|
+
if (this.#closed || this.#listeners.size === 0) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const wait = this.#retryMs;
|
|
207
|
+
this.#retryMs = Math.min(this.#retryMs * 2, MAX_RETRY_MS);
|
|
208
|
+
setTimeout(() => {
|
|
209
|
+
void this.#connect();
|
|
210
|
+
}, wait);
|
|
211
|
+
}
|
|
212
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { ChatError, DEFAULT_API_URL, NistrixChat, type ChatAttachment, type ChatMessage, type NistrixChatOptions, type VisitorStorage, type WebSocketFactory, } from "./chat.js";
|
|
2
|
+
export { NistrixIntents, type BookingOptions, type BookingRequest, type DataKind, type DataToken, type FieldValue, type Intent, type IntentStatus, type IntentType, type LeadRequest, type NistrixIntentsOptions, type Slot, type TicketRequest, } from "./intents.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking pages and lead forms in customers' sites and apps, with the same publishable key as
|
|
3
|
+
* the chat. Free times are public; a booking, a lead, or a ticket is an intent (nx_int_, ten minutes) that
|
|
4
|
+
* the organization's backend confirms with a secret key, unless the key confirms it on its own.
|
|
5
|
+
* Sensitive values (a CPF, a health note) become data tokens (nx_tok_) sent in their place.
|
|
6
|
+
*/
|
|
7
|
+
export interface NistrixIntentsOptions {
|
|
8
|
+
/** The organization's publishable key: nx_pk_live_... or, in its sandbox, nx_pk_test_.... */
|
|
9
|
+
publishableKey: string;
|
|
10
|
+
apiUrl?: string;
|
|
11
|
+
/** Native apps only: the bundle id or package name listed on the key. */
|
|
12
|
+
appId?: string;
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
}
|
|
15
|
+
export interface BookingOptions {
|
|
16
|
+
schedules: {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
timezone: string;
|
|
20
|
+
}[];
|
|
21
|
+
services: {
|
|
22
|
+
name: string;
|
|
23
|
+
durationMinutes: number | null;
|
|
24
|
+
price: string | null;
|
|
25
|
+
}[];
|
|
26
|
+
}
|
|
27
|
+
export interface Slot {
|
|
28
|
+
scheduleId: string;
|
|
29
|
+
scheduleName: string;
|
|
30
|
+
startsAt: string;
|
|
31
|
+
/** YYYY-MM-DD HH:MM in the schedule's zone: send it back as `start`. */
|
|
32
|
+
localStart: string;
|
|
33
|
+
timezone: string;
|
|
34
|
+
}
|
|
35
|
+
export type IntentType = "book_appointment" | "submit_lead" | "open_ticket";
|
|
36
|
+
export type IntentStatus = "pending" | "confirmed" | "failed" | "expired";
|
|
37
|
+
export interface Intent {
|
|
38
|
+
/** nx_int_...: hand it to your backend, which confirms it. */
|
|
39
|
+
id: string;
|
|
40
|
+
type: IntentType;
|
|
41
|
+
status: IntentStatus;
|
|
42
|
+
/** True for a sandbox key: nothing here reaches real customers. */
|
|
43
|
+
test: boolean;
|
|
44
|
+
expiresAt: string;
|
|
45
|
+
/** For a booking: scheduleName, serviceName, localStart, timezone; for a ticket: number. */
|
|
46
|
+
result: Record<string, unknown> | null;
|
|
47
|
+
error: string | null;
|
|
48
|
+
}
|
|
49
|
+
export interface BookingRequest {
|
|
50
|
+
scheduleId?: string;
|
|
51
|
+
service?: string;
|
|
52
|
+
/** YYYY-MM-DD HH:MM in the schedule's zone (a slot's `localStart`). */
|
|
53
|
+
start: string;
|
|
54
|
+
name: string;
|
|
55
|
+
phone: string;
|
|
56
|
+
email?: string;
|
|
57
|
+
notes?: string;
|
|
58
|
+
}
|
|
59
|
+
export type FieldValue = string | number | boolean | null;
|
|
60
|
+
export interface LeadRequest {
|
|
61
|
+
name?: string;
|
|
62
|
+
phone: string;
|
|
63
|
+
email?: string;
|
|
64
|
+
message?: string;
|
|
65
|
+
/** Customer fields by key; a sensitive value goes as its data token. */
|
|
66
|
+
fields?: Record<string, FieldValue>;
|
|
67
|
+
}
|
|
68
|
+
export interface TicketRequest {
|
|
69
|
+
subject: string;
|
|
70
|
+
description?: string;
|
|
71
|
+
category?: string;
|
|
72
|
+
name?: string;
|
|
73
|
+
phone: string;
|
|
74
|
+
email?: string;
|
|
75
|
+
}
|
|
76
|
+
export type DataKind = "cpf" | "cnpj" | "text";
|
|
77
|
+
export interface DataToken {
|
|
78
|
+
/** nx_tok_...: send it in place of the value. */
|
|
79
|
+
token: string;
|
|
80
|
+
kind: DataKind;
|
|
81
|
+
/** The last digits of a document; empty for text. */
|
|
82
|
+
hint: string;
|
|
83
|
+
expiresAt: string;
|
|
84
|
+
}
|
|
85
|
+
export declare class NistrixIntents {
|
|
86
|
+
#private;
|
|
87
|
+
constructor(options: NistrixIntentsOptions);
|
|
88
|
+
/** True for a sandbox key: nothing here reaches real customers. */
|
|
89
|
+
get test(): boolean;
|
|
90
|
+
/** The schedules open for booking and the services they offer. */
|
|
91
|
+
bookingOptions(): Promise<BookingOptions>;
|
|
92
|
+
/** Free times, a week from `from` (YYYY-MM-DD) by default. */
|
|
93
|
+
slots(query?: {
|
|
94
|
+
scheduleId?: string;
|
|
95
|
+
service?: string;
|
|
96
|
+
from?: string;
|
|
97
|
+
to?: string;
|
|
98
|
+
}): Promise<Slot[]>;
|
|
99
|
+
/** Asks for an appointment. Refused (ChatError 409, intent-refused) when the key confirms
|
|
100
|
+
* bookings on its own and the time was just taken. */
|
|
101
|
+
book(booking: BookingRequest): Promise<Intent>;
|
|
102
|
+
/** Sends a lead from a form. */
|
|
103
|
+
submitLead(lead: LeadRequest): Promise<Intent>;
|
|
104
|
+
/** Opens a support ticket (the customer is found or created by phone). */
|
|
105
|
+
openTicket(ticket: TicketRequest): Promise<Intent>;
|
|
106
|
+
/** Where an intent stands: pending, confirmed, failed (with the reason), or expired. */
|
|
107
|
+
intent(id: string): Promise<Intent>;
|
|
108
|
+
/** Keeps a sensitive value and returns a token for it (valid for a day, used once). */
|
|
109
|
+
tokenize(kind: DataKind, value: string): Promise<DataToken>;
|
|
110
|
+
}
|
package/dist/intents.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking pages and lead forms in customers' sites and apps, with the same publishable key as
|
|
3
|
+
* the chat. Free times are public; a booking, a lead, or a ticket is an intent (nx_int_, ten minutes) that
|
|
4
|
+
* the organization's backend confirms with a secret key, unless the key confirms it on its own.
|
|
5
|
+
* Sensitive values (a CPF, a health note) become data tokens (nx_tok_) sent in their place.
|
|
6
|
+
*/
|
|
7
|
+
import { ChatError, DEFAULT_API_URL } from "./chat.js";
|
|
8
|
+
const APP_HEADER = "X-Nistrix-App";
|
|
9
|
+
export class NistrixIntents {
|
|
10
|
+
#base;
|
|
11
|
+
#key;
|
|
12
|
+
#appId;
|
|
13
|
+
#fetch;
|
|
14
|
+
constructor(options) {
|
|
15
|
+
if (!options.publishableKey.startsWith("nx_pk_")) {
|
|
16
|
+
throw new Error("Use a publishable key (nx_pk_...); never a secret key in an app.");
|
|
17
|
+
}
|
|
18
|
+
const apiUrl = (options.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
|
|
19
|
+
this.#key = options.publishableKey;
|
|
20
|
+
this.#base = `${apiUrl}/widget/${encodeURIComponent(this.#key)}`;
|
|
21
|
+
this.#appId = options.appId;
|
|
22
|
+
this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
23
|
+
}
|
|
24
|
+
/** True for a sandbox key: nothing here reaches real customers. */
|
|
25
|
+
get test() {
|
|
26
|
+
return this.#key.startsWith("nx_pk_test_");
|
|
27
|
+
}
|
|
28
|
+
async #request(path, body) {
|
|
29
|
+
const headers = { accept: "application/json" };
|
|
30
|
+
if (this.#appId) {
|
|
31
|
+
headers[APP_HEADER] = this.#appId;
|
|
32
|
+
}
|
|
33
|
+
if (body !== undefined) {
|
|
34
|
+
headers["content-type"] = "application/json";
|
|
35
|
+
}
|
|
36
|
+
const response = await this.#fetch(`${this.#base}${path}`, {
|
|
37
|
+
method: body === undefined ? "GET" : "POST",
|
|
38
|
+
headers,
|
|
39
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
let code = null;
|
|
43
|
+
try {
|
|
44
|
+
const problem = (await response.json());
|
|
45
|
+
code = problem.type?.split(":").pop() ?? null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Not a problem document.
|
|
49
|
+
}
|
|
50
|
+
throw new ChatError(response.status, code);
|
|
51
|
+
}
|
|
52
|
+
return (await response.json());
|
|
53
|
+
}
|
|
54
|
+
/** The schedules open for booking and the services they offer. */
|
|
55
|
+
async bookingOptions() {
|
|
56
|
+
return this.#request("/booking");
|
|
57
|
+
}
|
|
58
|
+
/** Free times, a week from `from` (YYYY-MM-DD) by default. */
|
|
59
|
+
async slots(query = {}) {
|
|
60
|
+
const params = new URLSearchParams();
|
|
61
|
+
for (const [name, value] of Object.entries(query)) {
|
|
62
|
+
if (value) {
|
|
63
|
+
params.set(name, value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const search = params.size ? `?${params.toString()}` : "";
|
|
67
|
+
const { items } = await this.#request(`/slots${search}`);
|
|
68
|
+
return items;
|
|
69
|
+
}
|
|
70
|
+
/** Asks for an appointment. Refused (ChatError 409, intent-refused) when the key confirms
|
|
71
|
+
* bookings on its own and the time was just taken. */
|
|
72
|
+
async book(booking) {
|
|
73
|
+
return this.#request("/intents", { type: "book_appointment", ...booking });
|
|
74
|
+
}
|
|
75
|
+
/** Sends a lead from a form. */
|
|
76
|
+
async submitLead(lead) {
|
|
77
|
+
return this.#request("/intents", { type: "submit_lead", ...lead });
|
|
78
|
+
}
|
|
79
|
+
/** Opens a support ticket (the customer is found or created by phone). */
|
|
80
|
+
async openTicket(ticket) {
|
|
81
|
+
return this.#request("/intents", { type: "open_ticket", ...ticket });
|
|
82
|
+
}
|
|
83
|
+
/** Where an intent stands: pending, confirmed, failed (with the reason), or expired. */
|
|
84
|
+
async intent(id) {
|
|
85
|
+
return this.#request(`/intents/${encodeURIComponent(id)}`);
|
|
86
|
+
}
|
|
87
|
+
/** Keeps a sensitive value and returns a token for it (valid for a day, used once). */
|
|
88
|
+
async tokenize(kind, value) {
|
|
89
|
+
return this.#request("/tokens", { kind, value });
|
|
90
|
+
}
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nistrix/chat",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Nistrix chat for customers' sites and apps: visitors talk to the organization's agents and team with a publishable key (nx_pk_). Runs in browsers and React Native.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/nistrix-ltda/nistrix-chat-sdk.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.build.json",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"prepublishOnly": "pnpm test && pnpm build"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"registry": "https://registry.npmjs.org"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "~5.9.3",
|
|
34
|
+
"vitest": "^3.2.4"
|
|
35
|
+
}
|
|
36
|
+
}
|