opendesk-react-native 0.1.2 → 0.1.6

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/src/api.ts CHANGED
@@ -1,172 +1,195 @@
1
- import { OpenDeskError } from './errors';
2
- import type {
3
- Attachment,
4
- Conversation,
5
- Identity,
6
- Message,
7
- SendMessageInput,
8
- StartConversationInput,
9
- UploadFile,
10
- WidgetConfig,
11
- WidgetSession,
12
- } from './types';
13
-
14
- export type Fetcher = typeof fetch;
15
-
16
- export interface OpenDeskApiOptions {
17
- /** The openDesk origin, e.g. `https://desk.example.com`. `/v1` is added. */
18
- baseUrl: string;
19
- /** The chat widget's public key (`wk_…`). */
20
- widgetKey: string;
21
- fetcher?: Fetcher;
22
- }
23
-
24
- /**
25
- * Thin REST client for openDesk's visitor endpoints. Holds the visitor token once a
26
- * session exists and sends it as `Authorization: Visitor <token>`.
27
- */
28
- export class OpenDeskApi {
29
- readonly baseUrl: string;
30
- readonly widgetKey: string;
31
- visitorToken: string | null = null;
32
- private readonly fetcher: Fetcher;
33
-
34
- constructor(options: OpenDeskApiOptions) {
35
- this.baseUrl = options.baseUrl.replace(/\/widget\/v1\.js$/, '').replace(/\/+$/, '').replace(/\/v1$/, '');
36
- this.widgetKey = options.widgetKey;
37
- this.fetcher = options.fetcher ?? ((input, init) => fetch(input, init));
38
- }
39
-
40
- config(): Promise<WidgetConfig> {
41
- return this.request('GET', `/v1/widget/config?key=${encodeURIComponent(this.widgetKey)}`, undefined, false);
42
- }
43
-
44
- /** Starts a session, or resumes the current one when a visitor token is held. */
45
- async session(identity: Identity = {}): Promise<WidgetSession> {
46
- const session = await this.request<WidgetSession>(
47
- 'POST',
48
- '/v1/widget/session',
49
- { key: this.widgetKey, ...(this.visitorToken ? { visitor_token: this.visitorToken } : {}), ...identity },
50
- false,
51
- );
52
- this.visitorToken = session.visitor_token;
53
-
54
- return session;
55
- }
56
-
57
- async endSession(): Promise<void> {
58
- if (this.visitorToken) {
59
- await this.request('DELETE', '/v1/widget/session');
60
- }
61
- }
62
-
63
- async conversations(): Promise<Conversation[]> {
64
- const response = await this.request<{ data: Conversation[] | null }>('GET', '/v1/widget/conversations');
65
-
66
- return response.data ?? [];
67
- }
68
-
69
- startConversation(input: StartConversationInput): Promise<{ conversation: Conversation; message: Message }> {
70
- return this.request('POST', '/v1/widget/conversations', input);
71
- }
72
-
73
- async messages(conversationId: string): Promise<Message[]> {
74
- const response = await this.request<{ data: Message[] | null }>(
75
- 'GET',
76
- `/v1/widget/conversations/${encodeURIComponent(conversationId)}/messages`,
77
- );
78
-
79
- return response.data ?? [];
80
- }
81
-
82
- sendMessage(conversationId: string, input: SendMessageInput): Promise<Message> {
83
- return this.request('POST', `/v1/widget/conversations/${encodeURIComponent(conversationId)}/messages`, input);
84
- }
85
-
86
- async markSeen(conversationId: string): Promise<void> {
87
- await this.request('POST', `/v1/widget/conversations/${encodeURIComponent(conversationId)}/seen`, {});
88
- }
89
-
90
- /** Asks for a person after an AI answer. */
91
- async handoff(conversationId: string): Promise<void> {
92
- await this.request('POST', `/v1/widget/conversations/${encodeURIComponent(conversationId)}/handoff`, {});
93
- }
94
-
95
- /** Confirms or declines an action the AI proposed. */
96
- async decideAction(conversationId: string, actionId: string, decision: 'confirm' | 'decline'): Promise<void> {
97
- await this.request(
98
- 'POST',
99
- `/v1/widget/conversations/${encodeURIComponent(conversationId)}/actions/${encodeURIComponent(actionId)}`,
100
- { decision },
101
- );
102
- }
103
-
104
- async rateMessage(conversationId: string, messageId: string, rating: 'up' | 'down'): Promise<void> {
105
- await this.request(
106
- 'POST',
107
- `/v1/widget/conversations/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(messageId)}/rating`,
108
- { rating },
109
- );
110
- }
111
-
112
- /**
113
- * Uploads one file as multipart `file`. React Native's FormData takes `{ uri, name, type }`;
114
- * a browser needs a real Blob, so web Files are passed through as they are.
115
- */
116
- upload(file: UploadFile): Promise<Attachment> {
117
- const form = new FormData();
118
- if (isBlob(file)) {
119
- form.append('file', file, 'name' in file && typeof file.name === 'string' ? file.name : 'upload');
120
- } else {
121
- form.append('file', { uri: file.uri, name: file.name, type: file.type || 'application/octet-stream' } as unknown as Blob);
122
- }
123
-
124
- return this.request('POST', '/v1/widget/attachments', form);
125
- }
126
-
127
- private async request<T>(method: string, path: string, body?: unknown, authenticated = true): Promise<T> {
128
- const headers: Record<string, string> = { Accept: 'application/json' };
129
- let payload: BodyInit | undefined;
130
-
131
- if (body instanceof FormData) {
132
- payload = body;
133
- } else if (body !== undefined) {
134
- headers['Content-Type'] = 'application/json';
135
- payload = JSON.stringify(body);
136
- }
137
-
138
- if (authenticated && this.visitorToken) {
139
- headers.Authorization = `Visitor ${this.visitorToken}`;
140
- }
141
-
142
- let response: Response;
143
- try {
144
- response = await this.fetcher(this.baseUrl + path, { method, headers, body: payload });
145
- } catch {
146
- throw new OpenDeskError(0, 'network_error', "Can't reach the server");
147
- }
148
-
149
- if (!response.ok) {
150
- let code = 'error';
151
- let message = `Request failed (${response.status})`;
152
- try {
153
- const problem = (await response.json()) as { code?: string; detail?: string; title?: string };
154
- code = problem.code ?? code;
155
- message = problem.detail || problem.title || message;
156
- } catch {
157
- // Not a problem+json body.
158
- }
159
- throw new OpenDeskError(response.status, code, message);
160
- }
161
-
162
- if (response.status === 204 || !(response.headers.get('content-type') ?? '').includes('json')) {
163
- return undefined as T;
164
- }
165
-
166
- return (await response.json()) as T;
167
- }
168
- }
169
-
170
- function isBlob(file: UploadFile): file is Blob {
171
- return typeof Blob !== 'undefined' && file instanceof Blob;
172
- }
1
+ import { OpenDeskError } from './errors';
2
+ import type {
3
+ Attachment,
4
+ Conversation,
5
+ Identity,
6
+ Message,
7
+ SendMessageInput,
8
+ StartConversationInput,
9
+ UploadFile,
10
+ WidgetConfig,
11
+ WidgetSession,
12
+ } from './types';
13
+
14
+ export type Fetcher = typeof fetch;
15
+
16
+ export interface OpenDeskApiOptions {
17
+ /** The openDesk origin, e.g. `https://desk.example.com`. `/v1` is added. */
18
+ baseUrl: string;
19
+ /** The chat widget's public key (`wk_…`). */
20
+ widgetKey: string;
21
+ fetcher?: Fetcher;
22
+ }
23
+
24
+ /**
25
+ * Thin REST client for openDesk's visitor endpoints. Holds the visitor token once a
26
+ * session exists and sends it as `Authorization: Visitor <token>`.
27
+ */
28
+ export class OpenDeskApi {
29
+ readonly baseUrl: string;
30
+ readonly widgetKey: string;
31
+ visitorToken: string | null = null;
32
+ private readonly fetcher: Fetcher;
33
+
34
+ constructor(options: OpenDeskApiOptions) {
35
+ this.baseUrl = options.baseUrl.replace(/\/widget\/v1\.js$/, '').replace(/\/+$/, '').replace(/\/v1$/, '');
36
+ this.widgetKey = options.widgetKey;
37
+ this.fetcher = options.fetcher ?? ((input, init) => fetch(input, init));
38
+ }
39
+
40
+ config(): Promise<WidgetConfig> {
41
+ return this.request('GET', `/v1/widget/config?key=${encodeURIComponent(this.widgetKey)}`, undefined, false);
42
+ }
43
+
44
+ /** Starts a session, or resumes the current one when a visitor token is held. */
45
+ async session(identity: Identity = {}): Promise<WidgetSession> {
46
+ const session = await this.request<WidgetSession>(
47
+ 'POST',
48
+ '/v1/widget/session',
49
+ { key: this.widgetKey, ...(this.visitorToken ? { visitor_token: this.visitorToken } : {}), ...identity },
50
+ false,
51
+ );
52
+ this.visitorToken = session.visitor_token;
53
+
54
+ return session;
55
+ }
56
+
57
+ async endSession(): Promise<void> {
58
+ if (this.visitorToken) {
59
+ await this.request('DELETE', '/v1/widget/session');
60
+ }
61
+ }
62
+
63
+ async conversations(): Promise<Conversation[]> {
64
+ const response = await this.request<{ data: Conversation[] | null }>('GET', '/v1/widget/conversations');
65
+
66
+ return response.data ?? [];
67
+ }
68
+
69
+ startConversation(input: StartConversationInput): Promise<{ conversation: Conversation; message: Message }> {
70
+ return this.request('POST', '/v1/widget/conversations', input);
71
+ }
72
+
73
+ async messages(conversationId: string): Promise<Message[]> {
74
+ const response = await this.request<{ data: Message[] | null }>(
75
+ 'GET',
76
+ `/v1/widget/conversations/${encodeURIComponent(conversationId)}/messages`,
77
+ );
78
+
79
+ return response.data ?? [];
80
+ }
81
+
82
+ sendMessage(conversationId: string, input: SendMessageInput): Promise<Message> {
83
+ return this.request('POST', `/v1/widget/conversations/${encodeURIComponent(conversationId)}/messages`, input);
84
+ }
85
+
86
+ async markSeen(conversationId: string): Promise<void> {
87
+ await this.request('POST', `/v1/widget/conversations/${encodeURIComponent(conversationId)}/seen`, {});
88
+ }
89
+
90
+ /** Asks for a person after an AI answer. */
91
+ async handoff(conversationId: string): Promise<void> {
92
+ await this.request('POST', `/v1/widget/conversations/${encodeURIComponent(conversationId)}/handoff`, {});
93
+ }
94
+
95
+ /** Confirms or declines an action the AI proposed. */
96
+ async decideAction(conversationId: string, actionId: string, decision: 'confirm' | 'decline'): Promise<void> {
97
+ await this.request(
98
+ 'POST',
99
+ `/v1/widget/conversations/${encodeURIComponent(conversationId)}/actions/${encodeURIComponent(actionId)}`,
100
+ { decision },
101
+ );
102
+ }
103
+
104
+ async rateMessage(conversationId: string, messageId: string, rating: 'up' | 'down'): Promise<void> {
105
+ await this.request(
106
+ 'POST',
107
+ `/v1/widget/conversations/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(messageId)}/rating`,
108
+ { rating },
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Uploads one file as multipart `file`. React Native's FormData takes `{ uri, name, type }`;
114
+ * a browser needs a real Blob, so web Files are passed through as they are.
115
+ */
116
+ upload(file: UploadFile): Promise<Attachment> {
117
+ const form = new FormData();
118
+ if (isBlob(file)) {
119
+ form.append('file', file, 'name' in file && typeof file.name === 'string' ? file.name : 'upload');
120
+
121
+ return this.request('POST', '/v1/widget/attachments', form);
122
+ }
123
+ form.append('file', { uri: file.uri, name: file.name, type: file.type || 'application/octet-stream' } as unknown as Blob);
124
+
125
+ // Expo replaces `fetch` with expo/fetch, which can't send `{ uri }` parts; React Native's
126
+ // XMLHttpRequest reads the file from disk itself.
127
+ return this.request('POST', '/v1/widget/attachments', form, true, typeof XMLHttpRequest === 'undefined' ? this.fetcher : xhrFetch);
128
+ }
129
+
130
+ private async request<T>(method: string, path: string, body?: unknown, authenticated = true, fetcher = this.fetcher): Promise<T> {
131
+ const headers: Record<string, string> = { Accept: 'application/json' };
132
+ let payload: BodyInit | undefined;
133
+
134
+ if (body instanceof FormData) {
135
+ payload = body;
136
+ } else if (body !== undefined) {
137
+ headers['Content-Type'] = 'application/json';
138
+ payload = JSON.stringify(body);
139
+ }
140
+
141
+ if (authenticated && this.visitorToken) {
142
+ headers.Authorization = `Visitor ${this.visitorToken}`;
143
+ }
144
+
145
+ let response: Response;
146
+ try {
147
+ response = await fetcher(this.baseUrl + path, { method, headers, body: payload });
148
+ } catch (cause) {
149
+ throw new OpenDeskError(0, 'network_error', "Can't reach the server", cause);
150
+ }
151
+
152
+ if (!response.ok) {
153
+ let code = 'error';
154
+ let message = `Request failed (${response.status})`;
155
+ try {
156
+ const problem = (await response.json()) as { code?: string; detail?: string; title?: string };
157
+ code = problem.code ?? code;
158
+ message = problem.detail || problem.title || message;
159
+ } catch {
160
+ // Not a problem+json body.
161
+ }
162
+ throw new OpenDeskError(response.status, code, message);
163
+ }
164
+
165
+ if (response.status === 204 || !(response.headers.get('content-type') ?? '').includes('json')) {
166
+ return undefined as T;
167
+ }
168
+
169
+ return (await response.json()) as T;
170
+ }
171
+ }
172
+
173
+ function isBlob(file: UploadFile): file is Blob {
174
+ return typeof Blob !== 'undefined' && file instanceof Blob;
175
+ }
176
+
177
+ /** `fetch` over XMLHttpRequest, for the multipart uploads expo/fetch can't send. */
178
+ const xhrFetch: Fetcher = (input, init) =>
179
+ new Promise((resolve, reject) => {
180
+ const xhr = new XMLHttpRequest();
181
+ xhr.open(init?.method ?? 'GET', String(input));
182
+ Object.entries((init?.headers ?? {}) as Record<string, string>).forEach(([name, value]) => xhr.setRequestHeader(name, value));
183
+ xhr.onload = () => {
184
+ const empty = xhr.status === 204 || xhr.status === 205 || xhr.status === 304;
185
+ resolve(
186
+ new Response(empty ? null : xhr.responseText, {
187
+ status: xhr.status,
188
+ headers: { 'content-type': xhr.getResponseHeader('content-type') ?? '' },
189
+ }),
190
+ );
191
+ };
192
+ xhr.onerror = () => reject(new TypeError('Network request failed'));
193
+ xhr.ontimeout = xhr.onerror;
194
+ xhr.send((init?.body ?? null) as XMLHttpRequestBodyInit | null);
195
+ });
@@ -0,0 +1,5 @@
1
+ /** Metro resolves a required sound to an asset id that audio players accept. */
2
+ declare module '*.wav' {
3
+ const asset: number;
4
+ export default asset;
5
+ }