@giovchat/react-native 0.0.0-stage → 0.2.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 +333 -2
- package/dist/GiovChat.d.ts +100 -0
- package/dist/GiovChat.d.ts.map +1 -0
- package/dist/GiovChat.js +257 -0
- package/dist/GiovChat.js.map +1 -0
- package/dist/attachments.d.ts +121 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +185 -0
- package/dist/attachments.js.map +1 -0
- package/dist/brand-assets.d.ts +6 -0
- package/dist/brand-assets.d.ts.map +1 -0
- package/dist/brand-assets.js +9 -0
- package/dist/brand-assets.js.map +1 -0
- package/dist/client.d.ts +190 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +801 -0
- package/dist/client.js.map +1 -0
- package/dist/color.d.ts +25 -0
- package/dist/color.d.ts.map +1 -0
- package/dist/color.js +78 -0
- package/dist/color.js.map +1 -0
- package/dist/components/Attachments.d.ts +13 -0
- package/dist/components/Attachments.d.ts.map +1 -0
- package/dist/components/Attachments.js +42 -0
- package/dist/components/Attachments.js.map +1 -0
- package/dist/components/Branding.d.ts +6 -0
- package/dist/components/Branding.d.ts.map +1 -0
- package/dist/components/Branding.js +28 -0
- package/dist/components/Branding.js.map +1 -0
- package/dist/components/ChatMarkdown.d.ts +15 -0
- package/dist/components/ChatMarkdown.d.ts.map +1 -0
- package/dist/components/ChatMarkdown.js +72 -0
- package/dist/components/ChatMarkdown.js.map +1 -0
- package/dist/components/GiovBotMark.d.ts +6 -0
- package/dist/components/GiovBotMark.d.ts.map +1 -0
- package/dist/components/GiovBotMark.js +11 -0
- package/dist/components/GiovBotMark.js.map +1 -0
- package/dist/components/GreetingBubble.d.ts +7 -0
- package/dist/components/GreetingBubble.d.ts.map +1 -0
- package/dist/components/GreetingBubble.js +12 -0
- package/dist/components/GreetingBubble.js.map +1 -0
- package/dist/components/Header.d.ts +18 -0
- package/dist/components/Header.d.ts.map +1 -0
- package/dist/components/Header.js +67 -0
- package/dist/components/Header.js.map +1 -0
- package/dist/components/MessageBubble.d.ts +16 -0
- package/dist/components/MessageBubble.d.ts.map +1 -0
- package/dist/components/MessageBubble.js +49 -0
- package/dist/components/MessageBubble.js.map +1 -0
- package/dist/components/PreChatForm.d.ts +11 -0
- package/dist/components/PreChatForm.d.ts.map +1 -0
- package/dist/components/PreChatForm.js +72 -0
- package/dist/components/PreChatForm.js.map +1 -0
- package/dist/components/TypingIndicator.d.ts +9 -0
- package/dist/components/TypingIndicator.d.ts.map +1 -0
- package/dist/components/TypingIndicator.js +18 -0
- package/dist/components/TypingIndicator.js.map +1 -0
- package/dist/components/WidgetStyles.d.ts +3 -0
- package/dist/components/WidgetStyles.d.ts.map +1 -0
- package/dist/components/WidgetStyles.js +9 -0
- package/dist/components/WidgetStyles.js.map +1 -0
- package/dist/components/bubbleStyles.d.ts +47 -0
- package/dist/components/bubbleStyles.d.ts.map +1 -0
- package/dist/components/bubbleStyles.js +19 -0
- package/dist/components/bubbleStyles.js.map +1 -0
- package/dist/components/index.d.ts +11 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +14 -0
- package/dist/components/index.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/i18n.d.ts +73 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +292 -0
- package/dist/i18n.js.map +1 -0
- package/dist/identity.d.ts +41 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +41 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.d.ts +56 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +219 -0
- package/dist/markdown.js.map +1 -0
- package/dist/outbox.d.ts +68 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +168 -0
- package/dist/outbox.js.map +1 -0
- package/dist/protocol.d.ts +9 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +34 -0
- package/dist/protocol.js.map +1 -0
- package/dist/styleOverrides.d.ts +37 -0
- package/dist/styleOverrides.d.ts.map +1 -0
- package/dist/styleOverrides.js +54 -0
- package/dist/styleOverrides.js.map +1 -0
- package/dist/types.d.ts +111 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/unread.d.ts +9 -0
- package/dist/unread.d.ts.map +1 -0
- package/dist/unread.js +49 -0
- package/dist/unread.js.map +1 -0
- package/dist/useChatSession.d.ts +78 -0
- package/dist/useChatSession.d.ts.map +1 -0
- package/dist/useChatSession.js +796 -0
- package/dist/useChatSession.js.map +1 -0
- package/package.json +59 -4
- package/src/GiovChat.tsx +530 -0
- package/src/attachments.ts +218 -0
- package/src/brand-assets.ts +5 -0
- package/src/client.ts +758 -0
- package/src/color.ts +74 -0
- package/src/components/Attachments.tsx +75 -0
- package/src/components/Branding.tsx +35 -0
- package/src/components/ChatMarkdown.tsx +111 -0
- package/src/components/GiovBotMark.tsx +19 -0
- package/src/components/GreetingBubble.tsx +20 -0
- package/src/components/Header.tsx +131 -0
- package/src/components/MessageBubble.tsx +121 -0
- package/src/components/PreChatForm.tsx +183 -0
- package/src/components/TypingIndicator.tsx +34 -0
- package/src/components/WidgetStyles.tsx +5 -0
- package/src/components/bubbleStyles.ts +16 -0
- package/src/components/index.ts +14 -0
- package/src/errors.ts +30 -0
- package/src/i18n.ts +351 -0
- package/src/identity.ts +54 -0
- package/src/index.ts +68 -0
- package/src/markdown.ts +227 -0
- package/src/outbox.ts +175 -0
- package/src/protocol.ts +31 -0
- package/src/styleOverrides.ts +85 -0
- package/src/types.ts +119 -0
- package/src/unread.ts +39 -0
- package/src/useChatSession.ts +695 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
import type { GiovChatContact, GiovChatSession, GiovMessage, GiovWidgetConfig, MessageHandler, StatusHandler } from "./types";
|
|
2
|
+
import { GiovChatError, chatError, retryAfterSeconds } from "./errors";
|
|
3
|
+
import { object, parseMessage } from "./protocol";
|
|
4
|
+
import { safeImageUrl } from "./identity";
|
|
5
|
+
import { deviceLocale } from "./i18n";
|
|
6
|
+
import { Outbox } from "./outbox";
|
|
7
|
+
import {
|
|
8
|
+
buildUploadForm,
|
|
9
|
+
normalizeAttachmentPolicy,
|
|
10
|
+
parseAttachmentAccess,
|
|
11
|
+
sanitizePreview,
|
|
12
|
+
type AttachmentAccess,
|
|
13
|
+
type GiovAttachmentPreview,
|
|
14
|
+
type GiovChatPickedFile,
|
|
15
|
+
} from "./attachments";
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_BASE_URL = "https://api.giovchat.app";
|
|
18
|
+
|
|
19
|
+
/** Keep in step with package.json; sent so the dashboard can show where the chat is installed. */
|
|
20
|
+
export const SDK_VERSION = "0.2.0";
|
|
21
|
+
export const CLIENT_HEADER = { "X-Giov-Client": `react-native/${SDK_VERSION}` };
|
|
22
|
+
|
|
23
|
+
export function normalizeBaseUrl(value: string): string {
|
|
24
|
+
const url = new URL(value);
|
|
25
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
26
|
+
throw new GiovChatError("invalid_response", "Use an HTTP(S) API URL without credentials, query parameters or fragments.");
|
|
27
|
+
}
|
|
28
|
+
return url.toString().replace(/\/$/, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function request(baseUrl: string, path: string, init: RequestInit = {}): Promise<Record<string, unknown>> {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
let timedOut = false;
|
|
34
|
+
const abort = () => controller.abort();
|
|
35
|
+
if (init.signal?.aborted) controller.abort();
|
|
36
|
+
init.signal?.addEventListener("abort", abort);
|
|
37
|
+
const timeout = setTimeout(() => { timedOut = true; controller.abort(); }, 15000);
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetch(normalizeBaseUrl(baseUrl) + path, { ...init, signal: controller.signal });
|
|
40
|
+
const data = object(await response.json().catch(() => null));
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
// Protocol v1: 401/403 the session is gone; 409 (or the code) the conversation is closed;
|
|
43
|
+
// 503 workspace_busy the workspace is at its plan's limit of open conversations; 401
|
|
44
|
+
// invalid_user_hash the workspace requires a signed identity and userHash is wrong or missing.
|
|
45
|
+
const code = data.error === "invalid_user_hash" ? "identity_rejected"
|
|
46
|
+
: response.status === 429 ? "rate_limited"
|
|
47
|
+
: response.status === 401 || response.status === 403 ? "invalid_session"
|
|
48
|
+
: response.status === 409 || data.error === "conversation_closed" ? "conversation_closed"
|
|
49
|
+
: response.status === 503 && data.error === "workspace_busy" ? "workspace_busy" : "server";
|
|
50
|
+
throw new GiovChatError(code, code === "identity_rejected" ? "This workspace requires a signed visitor identity (userId and userHash)."
|
|
51
|
+
: code === "invalid_session" ? "Chat session expired. Please start a new chat."
|
|
52
|
+
: code === "conversation_closed" ? "This conversation has ended."
|
|
53
|
+
: code === "workspace_busy" ? "The team is busy right now. Please try again later."
|
|
54
|
+
: code === "rate_limited" ? "Too many requests. Please wait a moment."
|
|
55
|
+
: "Chat is unavailable. Please try again.", response.status, retryAfterSeconds(response.headers?.get?.("Retry-After")));
|
|
56
|
+
}
|
|
57
|
+
return data;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (init.signal?.aborted) throw new GiovChatError("disconnected", "Chat closed.");
|
|
60
|
+
if (timedOut) throw new GiovChatError("timeout", "Chat request timed out. Please try again.");
|
|
61
|
+
throw chatError(error);
|
|
62
|
+
} finally {
|
|
63
|
+
clearTimeout(timeout);
|
|
64
|
+
init.signal?.removeEventListener("abort", abort);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function fetchWidgetConfig(baseUrl: string, tenantKey: string, signal?: AbortSignal): Promise<GiovWidgetConfig> {
|
|
69
|
+
const data = await request(baseUrl, "/widget/config?tenantId=" + encodeURIComponent(tenantKey), { signal, headers: CLIENT_HEADER });
|
|
70
|
+
if (typeof data.title !== "string" || typeof data.primaryColor !== "string" ||
|
|
71
|
+
!["light", "dark", "auto"].includes(String(data.theme))) {
|
|
72
|
+
throw new GiovChatError("invalid_response", "Invalid widget configuration.");
|
|
73
|
+
}
|
|
74
|
+
const fields = object(data.requestUserInfo);
|
|
75
|
+
const attachments = normalizeAttachmentPolicy(data.attachments);
|
|
76
|
+
return {
|
|
77
|
+
title: data.title, primaryColor: data.primaryColor, theme: data.theme as GiovWidgetConfig["theme"],
|
|
78
|
+
greetingText: typeof data.greetingText === "string" ? data.greetingText : "Hi! How can we help?",
|
|
79
|
+
textArea: typeof data.textArea === "string" ? data.textArea : undefined,
|
|
80
|
+
botEnabled: data.botEnabled === true,
|
|
81
|
+
botName: typeof data.botName === "string" ? data.botName : undefined,
|
|
82
|
+
botAvatarUrl: safeImageUrl(data.botAvatarUrl) ?? undefined,
|
|
83
|
+
avatarUrl: typeof data.avatarUrl === "string" ? data.avatarUrl : undefined,
|
|
84
|
+
showBranding: data.showBranding !== false,
|
|
85
|
+
requestUserInfo: { email: fields.email === true, phone: fields.phone === true,
|
|
86
|
+
firstName: fields.firstName === true, lastName: fields.lastName === true },
|
|
87
|
+
...(attachments ? { attachments } : {}),
|
|
88
|
+
...(privacyPolicyUrl(data.privacyPolicyUrl) ? { privacyPolicyUrl: privacyPolicyUrl(data.privacyPolicyUrl) } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** As the web widget reads it: an https URL without credentials, else nothing. */
|
|
93
|
+
function privacyPolicyUrl(value: unknown): string | undefined {
|
|
94
|
+
if (typeof value !== "string") return undefined;
|
|
95
|
+
try {
|
|
96
|
+
const url = new URL(value);
|
|
97
|
+
return url.protocol === "https:" && !url.username && !url.password ? url.toString() : undefined;
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Creates a credential-bearing visitor session. Resuming requires the entire session. */
|
|
104
|
+
export async function createConversation(
|
|
105
|
+
baseUrl: string, tenantKey: string, contact?: GiovChatContact,
|
|
106
|
+
existingSession?: GiovChatSession, signal?: AbortSignal,
|
|
107
|
+
): Promise<GiovChatSession> {
|
|
108
|
+
const data = await request(baseUrl, "/conversations", {
|
|
109
|
+
method: "POST", signal,
|
|
110
|
+
headers: { "Content-Type": "application/json",
|
|
111
|
+
...(existingSession ? { Authorization: "Bearer " + existingSession.sessionToken } : {}) },
|
|
112
|
+
body: JSON.stringify({ tenantId: tenantKey, ...contact, conversationId: existingSession?.conversationId }),
|
|
113
|
+
});
|
|
114
|
+
if (typeof data.conversationId !== "string" || !data.conversationId ||
|
|
115
|
+
typeof data.sessionToken !== "string" || !/^[a-f0-9]{64}$/.test(data.sessionToken)) {
|
|
116
|
+
throw new GiovChatError("invalid_response", "Invalid chat session response.");
|
|
117
|
+
}
|
|
118
|
+
return { conversationId: data.conversationId, sessionToken: data.sessionToken };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A short-lived visitor token (the API says 15 minutes) and, when sent, the attachment download token. */
|
|
122
|
+
type WidgetToken = { token: string; expiresAt: number; attachmentAccess: AttachmentAccess | null };
|
|
123
|
+
|
|
124
|
+
async function fetchWidgetToken(baseUrl: string, tenantKey: string, session: GiovChatSession, signal?: AbortSignal): Promise<WidgetToken> {
|
|
125
|
+
const data = await request(baseUrl, "/auth/widget-token", {
|
|
126
|
+
method: "POST", signal,
|
|
127
|
+
headers: { "Content-Type": "application/json", Authorization: "Bearer " + session.sessionToken },
|
|
128
|
+
body: JSON.stringify({ tenantId: tenantKey, conversationId: session.conversationId }),
|
|
129
|
+
});
|
|
130
|
+
if (typeof data.token !== "string" || !data.token) throw new GiovChatError("invalid_response", "Invalid chat token response.");
|
|
131
|
+
// The API reports the expiry in seconds; assume a little under 15 minutes when it does not.
|
|
132
|
+
const expiresAt = typeof data.expiresAt === "number" && Number.isFinite(data.expiresAt) ? data.expiresAt * 1000 : Date.now() + 14 * 60_000;
|
|
133
|
+
return { token: data.token, expiresAt, attachmentAccess: parseAttachmentAccess(data.attachmentAccess) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface GiovConversationHistory {
|
|
137
|
+
messages: GiovMessage[];
|
|
138
|
+
closed: boolean;
|
|
139
|
+
/** The teammate who has the conversation, when one does; the header shows them. */
|
|
140
|
+
assignedAgentName?: string | null;
|
|
141
|
+
assignedAgentAvatar?: string | null;
|
|
142
|
+
attachmentAccess?: AttachmentAccess | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function fetchConversationHistory(
|
|
146
|
+
baseUrl: string, tenantKey: string, session: GiovChatSession, signal?: AbortSignal,
|
|
147
|
+
): Promise<GiovConversationHistory> {
|
|
148
|
+
const load = async () => {
|
|
149
|
+
const { token } = await fetchWidgetToken(baseUrl, tenantKey, session, signal);
|
|
150
|
+
return request(baseUrl, "/conversations/" + encodeURIComponent(session.conversationId) + "/history", {
|
|
151
|
+
signal, headers: { Authorization: "Bearer " + token },
|
|
152
|
+
}).catch((error: unknown) => { throw Object.assign(chatError(error), { fromHistory: true }); });
|
|
153
|
+
};
|
|
154
|
+
// Protocol "Tokens": a 401 from history renews the visitor token once; a second one (or a 401
|
|
155
|
+
// from the token route) means the session is gone.
|
|
156
|
+
const data = await load().catch((error: GiovChatError & { fromHistory?: boolean }) => {
|
|
157
|
+
if (error.fromHistory && error.status === 401) return load();
|
|
158
|
+
throw error;
|
|
159
|
+
});
|
|
160
|
+
if (!Array.isArray(data.messages)) throw new GiovChatError("invalid_response", "Invalid conversation history.");
|
|
161
|
+
const assignedAgentName = typeof data.assignedAgentName === "string" ? data.assignedAgentName.trim() || null : null;
|
|
162
|
+
// History rows carry no sender name; a human reply is shown under the assigned teammate, as in the widget.
|
|
163
|
+
const messages = data.messages.map(parseMessage).filter((m): m is GiovMessage => m !== null).map((message) =>
|
|
164
|
+
message.role === "agent" && !message.isBot && !message.isAutomated && !message.senderName && assignedAgentName
|
|
165
|
+
? { ...message, senderName: assignedAgentName } : message);
|
|
166
|
+
return {
|
|
167
|
+
messages, closed: data.status === "closed",
|
|
168
|
+
assignedAgentName,
|
|
169
|
+
assignedAgentAvatar: assignedAgentName ? safeImageUrl(data.assignedAgentAvatar) : null,
|
|
170
|
+
attachmentAccess: parseAttachmentAccess(data.attachmentAccess),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface ChatClientOptions {
|
|
175
|
+
baseUrl?: string;
|
|
176
|
+
tenantKey: string;
|
|
177
|
+
/**
|
|
178
|
+
* Messages waiting for the server. Pass one that outlives this client (the chat UI does) so
|
|
179
|
+
* messages typed while reconnecting or in the background are sent once a socket is back.
|
|
180
|
+
*/
|
|
181
|
+
outbox?: Outbox;
|
|
182
|
+
/**
|
|
183
|
+
* Send the outbox as soon as a socket opens (default). The chat UI turns this off and calls
|
|
184
|
+
* `flush()` after the history catch-up, in the protocol's order: open, history, merge, resend.
|
|
185
|
+
*/
|
|
186
|
+
flushOnOpen?: boolean;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A GiovBot reply arriving word by word: the whole text so far, then `end` once it is saved or dropped. */
|
|
190
|
+
export type GiovStreamEvent = { streamId: string; content: string; end?: false } | { streamId: string; end: true };
|
|
191
|
+
|
|
192
|
+
export interface GiovUploadResult {
|
|
193
|
+
url: string;
|
|
194
|
+
filename: string;
|
|
195
|
+
preview?: GiovAttachmentPreview;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export type GiovPushPlatform = "ios" | "android";
|
|
199
|
+
/**
|
|
200
|
+
* `environment`: APNs only; "sandbox" for development builds (Xcode), "production" (the default) for
|
|
201
|
+
* TestFlight and the App Store. `appId`: the bundle id or package name. `locale`: the language of the
|
|
202
|
+
* generic notification text when the workspace hides message text (default: the device language).
|
|
203
|
+
*/
|
|
204
|
+
export type GiovPushOptions = { environment?: "sandbox" | "production"; appId?: string; locale?: string };
|
|
205
|
+
export type GiovPushRegistration = { registered: true } | { registered: false; reason: "unsupported" | "no_conversation" | "failed" };
|
|
206
|
+
/**
|
|
207
|
+
* Visitor push registration (docs/protocol/README.md, "Push notifications"). Feature-detected: a
|
|
208
|
+
* server without it answers 404/405/501 and registration degrades to a no-op.
|
|
209
|
+
*/
|
|
210
|
+
export const PUSH_TOKEN_PATH = "/push-token";
|
|
211
|
+
/** BCP 47 tags the server stores with a device ("pt-BR", "fr"); anything else is left out. */
|
|
212
|
+
const PUSH_LOCALE = /^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{1,8}){0,3}$/;
|
|
213
|
+
|
|
214
|
+
const SEND_FAILED = "Message could not be sent. Please try again.";
|
|
215
|
+
|
|
216
|
+
/** Connection timings from the visitor protocol (docs/protocol/README.md). */
|
|
217
|
+
export const OPEN_TIMEOUT_MS = 10_000;
|
|
218
|
+
export const PING_INTERVAL_MS = 25_000;
|
|
219
|
+
export const PONG_TIMEOUT_MS = 10_000;
|
|
220
|
+
/** Retry waits: min(1500 × 2^attempt, 15000) ms plus a little jitter; reset after a successful connect. */
|
|
221
|
+
export function reconnectDelay(attempt: number, jitter = Math.random()): number {
|
|
222
|
+
return Math.min(1500 * 2 ** attempt, 15000) + Math.floor(jitter * 400);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Transport only. Session credentials are never derived from contact information. */
|
|
226
|
+
export class ChatClient {
|
|
227
|
+
private ws: WebSocket | null = null;
|
|
228
|
+
private messageCbs = new Set<MessageHandler>();
|
|
229
|
+
private statusCbs = new Set<StatusHandler>();
|
|
230
|
+
private typingCbs = new Set<(typing: boolean, isBot?: boolean) => void>();
|
|
231
|
+
private errorCbs = new Set<(error: GiovChatError) => void>();
|
|
232
|
+
private ackCbs = new Set<(clientMessageId: string, message: GiovMessage) => void>();
|
|
233
|
+
private streamCbs = new Set<(event: GiovStreamEvent) => void>();
|
|
234
|
+
private accessCbs = new Set<(access: AttachmentAccess) => void>();
|
|
235
|
+
private pending = new Map<string, Array<{ resolve: (message: GiovMessage) => void; reject: (error: GiovChatError) => void }>>();
|
|
236
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
237
|
+
private heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
238
|
+
private watchdog: ReturnType<typeof setInterval> | null = null;
|
|
239
|
+
private resendTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
240
|
+
private controller: AbortController | null = null;
|
|
241
|
+
private cancelHandshake: (() => void) | null = null;
|
|
242
|
+
private generation = 0;
|
|
243
|
+
private desired = false;
|
|
244
|
+
private attempt = 0;
|
|
245
|
+
private session: GiovChatSession | null = null;
|
|
246
|
+
private widgetToken: WidgetToken | null = null;
|
|
247
|
+
private tokenRequest: Promise<WidgetToken> | null = null;
|
|
248
|
+
private pushUnsupported = false;
|
|
249
|
+
private readonly baseUrl: string;
|
|
250
|
+
readonly outbox: Outbox;
|
|
251
|
+
|
|
252
|
+
constructor(private readonly options: ChatClientOptions) {
|
|
253
|
+
this.baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
254
|
+
this.outbox = options.outbox ?? new Outbox();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
onMessage(cb: MessageHandler): () => void { this.messageCbs.add(cb); return () => { this.messageCbs.delete(cb); }; }
|
|
258
|
+
onStatusChange(cb: StatusHandler): () => void { this.statusCbs.add(cb); return () => { this.statusCbs.delete(cb); }; }
|
|
259
|
+
/** `isBot` is server-reported identity; undefined means an older/unknown sender. */
|
|
260
|
+
onTyping(cb: (typing: boolean, isBot?: boolean) => void): () => void { this.typingCbs.add(cb); return () => { this.typingCbs.delete(cb); }; }
|
|
261
|
+
onError(cb: (error: GiovChatError) => void): () => void { this.errorCbs.add(cb); return () => { this.errorCbs.delete(cb); }; }
|
|
262
|
+
/** The server saved a visitor message: the stored copy replaces the one shown while sending. */
|
|
263
|
+
onAck(cb: (clientMessageId: string, message: GiovMessage) => void): () => void { this.ackCbs.add(cb); return () => { this.ackCbs.delete(cb); }; }
|
|
264
|
+
onStream(cb: (event: GiovStreamEvent) => void): () => void { this.streamCbs.add(cb); return () => { this.streamCbs.delete(cb); }; }
|
|
265
|
+
/** A newer attachment download token arrived (with a visitor token). */
|
|
266
|
+
onAttachmentAccess(cb: (access: AttachmentAccess) => void): () => void { this.accessCbs.add(cb); return () => { this.accessCbs.delete(cb); }; }
|
|
267
|
+
private emit<T>(callbacks: Set<(value: T) => void>, value: T): void {
|
|
268
|
+
for (const cb of callbacks) { try { cb(value); } catch { /* Host callbacks cannot break transport cleanup. */ } }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async connect(session: GiovChatSession): Promise<void> {
|
|
272
|
+
// Messages already waiting (sendMessage before connect) are kept: they go out on this socket.
|
|
273
|
+
this.teardown();
|
|
274
|
+
this.desired = true;
|
|
275
|
+
this.session = session;
|
|
276
|
+
this.attempt = 0;
|
|
277
|
+
await this.open(this.generation);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private async token(force = false): Promise<WidgetToken> {
|
|
281
|
+
const session = this.session;
|
|
282
|
+
if (!session) throw new GiovChatError("disconnected", "Chat closed.");
|
|
283
|
+
if (!force && this.widgetToken && this.widgetToken.expiresAt - Date.now() > 60_000) return this.widgetToken;
|
|
284
|
+
// One request at a time. Teardown forgets it, so the next connection never waits on an aborted one.
|
|
285
|
+
const generation = this.generation;
|
|
286
|
+
const pending: Promise<WidgetToken> = this.tokenRequest ??= fetchWidgetToken(this.baseUrl, this.options.tenantKey, session, this.controller?.signal)
|
|
287
|
+
.then((value) => {
|
|
288
|
+
if (this.session === session && this.generation === generation) {
|
|
289
|
+
this.widgetToken = value;
|
|
290
|
+
if (value.attachmentAccess) this.emit(this.accessCbs, value.attachmentAccess);
|
|
291
|
+
}
|
|
292
|
+
return value;
|
|
293
|
+
})
|
|
294
|
+
.finally(() => { if (this.tokenRequest === pending) this.tokenRequest = null; });
|
|
295
|
+
return pending;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The current visitor token, for attachment downloads when no access token is usable. */
|
|
299
|
+
get visitorToken(): string | null { return this.widgetToken?.token ?? null; }
|
|
300
|
+
/** When the current visitor token expires (milliseconds); 0 without one. */
|
|
301
|
+
get visitorTokenExpiresAt(): number { return this.widgetToken?.expiresAt ?? 0; }
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Fetches a new visitor token (and with it, usually, a newer attachment access token), e.g. before
|
|
305
|
+
* attachment links that use the visitor token expire. Resolves false when it could not; never throws.
|
|
306
|
+
*/
|
|
307
|
+
async renewVisitorToken(): Promise<boolean> {
|
|
308
|
+
if (!this.session) return false;
|
|
309
|
+
try { await this.token(true); return true; } catch { return false; }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async open(generation: number): Promise<void> {
|
|
313
|
+
if (!this.desired || !this.session || generation !== this.generation) return;
|
|
314
|
+
this.controller = new AbortController();
|
|
315
|
+
const { token } = await this.token(true);
|
|
316
|
+
if (!this.desired || generation !== this.generation) throw new GiovChatError("disconnected", "Chat closed.");
|
|
317
|
+
const url = new URL(this.baseUrl + "/conversations/" + encodeURIComponent(this.session.conversationId) + "/ws");
|
|
318
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
319
|
+
url.searchParams.set("token", token);
|
|
320
|
+
url.searchParams.set("role", "user");
|
|
321
|
+
await new Promise<void>((resolve, reject) => {
|
|
322
|
+
const ws = new WebSocket(url.toString());
|
|
323
|
+
this.ws = ws;
|
|
324
|
+
let opened = false;
|
|
325
|
+
let settled = false;
|
|
326
|
+
let pongDeadline: ReturnType<typeof setTimeout> | null = null;
|
|
327
|
+
const finish = (error?: GiovChatError) => {
|
|
328
|
+
if (settled) return;
|
|
329
|
+
settled = true;
|
|
330
|
+
clearTimeout(timeout);
|
|
331
|
+
this.cancelHandshake = null;
|
|
332
|
+
if (error) reject(error); else resolve();
|
|
333
|
+
};
|
|
334
|
+
const timeout = setTimeout(() => {
|
|
335
|
+
finish(new GiovChatError("timeout", "Chat connection timed out."));
|
|
336
|
+
drop();
|
|
337
|
+
}, OPEN_TIMEOUT_MS);
|
|
338
|
+
this.cancelHandshake = () => finish(new GiovChatError("disconnected", "Chat closed."));
|
|
339
|
+
ws.onopen = () => {
|
|
340
|
+
if (generation !== this.generation || !this.desired || settled) { ws.close(); return; }
|
|
341
|
+
opened = true;
|
|
342
|
+
this.attempt = 0;
|
|
343
|
+
this.emit(this.statusCbs, true);
|
|
344
|
+
// A ping every 25 s; no pong within 10 s means the socket is dead.
|
|
345
|
+
this.heartbeat = setInterval(() => {
|
|
346
|
+
try { ws.send(JSON.stringify({ type: "ping" })); } catch { drop(); return; }
|
|
347
|
+
if (pongDeadline) clearTimeout(pongDeadline);
|
|
348
|
+
pongDeadline = setTimeout(drop, PONG_TIMEOUT_MS);
|
|
349
|
+
}, PING_INTERVAL_MS);
|
|
350
|
+
// A message on a socket that never answered means the socket is dead: drop it so the
|
|
351
|
+
// reconnect resends everything still waiting.
|
|
352
|
+
this.watchdog = setInterval(() => { if (this.outbox.stale()) drop(); }, 3000);
|
|
353
|
+
finish();
|
|
354
|
+
if (this.options.flushOnOpen !== false) this.flush();
|
|
355
|
+
};
|
|
356
|
+
ws.onerror = () => {
|
|
357
|
+
finish(new GiovChatError("connection_failed", "Unable to connect to chat."));
|
|
358
|
+
ws.close();
|
|
359
|
+
};
|
|
360
|
+
// A dead socket can take a minute to report its close (Android's OkHttp waits out the close
|
|
361
|
+
// handshake), so it is treated as closed at once; its late onclose is then ignored.
|
|
362
|
+
const drop = () => {
|
|
363
|
+
try { ws.close(); } catch { /* Already closing. */ }
|
|
364
|
+
closed();
|
|
365
|
+
};
|
|
366
|
+
const closed = (event?: { code?: number; reason?: string }) => {
|
|
367
|
+
if (pongDeadline) clearTimeout(pongDeadline);
|
|
368
|
+
pongDeadline = null;
|
|
369
|
+
if (generation !== this.generation || this.ws !== ws) return;
|
|
370
|
+
this.ws = null;
|
|
371
|
+
this.outbox.socketClosed();
|
|
372
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
373
|
+
if (this.watchdog) clearInterval(this.watchdog);
|
|
374
|
+
this.heartbeat = null;
|
|
375
|
+
this.watchdog = null;
|
|
376
|
+
this.emit(this.statusCbs, false);
|
|
377
|
+
finish(new GiovChatError("connection_failed", "Chat connection closed."));
|
|
378
|
+
if (event?.code === 1000 && event.reason === "Conversation deleted") {
|
|
379
|
+
// The conversation is gone: no reconnect; the chat forgets the stored session.
|
|
380
|
+
this.desired = false;
|
|
381
|
+
this.emit(this.errorCbs, new GiovChatError("conversation_closed", "This conversation has ended."));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (opened) this.reconnect(generation);
|
|
385
|
+
};
|
|
386
|
+
ws.onclose = closed;
|
|
387
|
+
ws.onmessage = (event) => {
|
|
388
|
+
if (generation !== this.generation || this.ws !== ws) return;
|
|
389
|
+
let data: Record<string, unknown>;
|
|
390
|
+
try { data = object(JSON.parse(String(event.data))); } catch { return; }
|
|
391
|
+
this.receive(data, () => { if (pongDeadline) clearTimeout(pongDeadline); pongDeadline = null; }, ws);
|
|
392
|
+
};
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private receive(data: Record<string, unknown>, pong: () => void, ws: WebSocket): void {
|
|
397
|
+
if (data.type === "pong") { pong(); return; }
|
|
398
|
+
if (data.type === "typing" || data.type === "typing_end") {
|
|
399
|
+
if (data.role === "agent") {
|
|
400
|
+
const typing = data.type === "typing";
|
|
401
|
+
// Kept on typing_end too: a stop from the other sender (person or GiovBot) must not hide the indicator.
|
|
402
|
+
const isBot = typeof data.isBot === "boolean" ? data.isBot : undefined;
|
|
403
|
+
for (const cb of this.typingCbs) {
|
|
404
|
+
try { cb(typing, isBot); } catch { /* Host callbacks cannot break transport cleanup. */ }
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (data.type === "stream" && typeof data.streamId === "string") {
|
|
410
|
+
this.emit(this.streamCbs, { streamId: data.streamId, content: typeof data.content === "string" ? data.content : "" });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (data.type === "stream_end" && typeof data.streamId === "string") {
|
|
414
|
+
this.emit(this.streamCbs, { streamId: data.streamId, end: true });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (data.type === "error") { this.serverError(data, ws); return; }
|
|
418
|
+
if (data.type === "message_ack" && typeof data.clientMessageId === "string") {
|
|
419
|
+
const message = parseMessage({ ...object(data.message), role: "visitor" });
|
|
420
|
+
const entry = this.outbox.acknowledge(data.clientMessageId);
|
|
421
|
+
if (message) {
|
|
422
|
+
for (const cb of this.ackCbs) { try { cb(data.clientMessageId, message); } catch { /* Host callbacks cannot break transport cleanup. */ } }
|
|
423
|
+
this.settle(data.clientMessageId, message);
|
|
424
|
+
} else if (entry) {
|
|
425
|
+
this.settle(data.clientMessageId, null);
|
|
426
|
+
}
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (data.type && data.type !== "message") return;
|
|
430
|
+
const message = parseMessage(data);
|
|
431
|
+
// The saved reply takes the place of its streamed draft, under the draft's key so it is not redrawn.
|
|
432
|
+
if (message && typeof data.streamId === "string") message.clientMessageId = `stream:${data.streamId}`;
|
|
433
|
+
if (message) this.emit(this.messageCbs, message);
|
|
434
|
+
if (message && typeof data.streamId === "string") this.emit(this.streamCbs, { streamId: data.streamId, end: true });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Error frames: a save failure names its message; refusals and rate limits apply to what is on the socket. */
|
|
438
|
+
private serverError(data: Record<string, unknown>, ws: WebSocket): void {
|
|
439
|
+
if (data.error === "message_failed" && typeof data.clientMessageId === "string") {
|
|
440
|
+
const outcome = this.outbox.rejected(data.clientMessageId);
|
|
441
|
+
if (!outcome) return;
|
|
442
|
+
if (outcome.failed) {
|
|
443
|
+
this.fail(data.clientMessageId);
|
|
444
|
+
this.emit(this.errorCbs, new GiovChatError("send_failed", SEND_FAILED));
|
|
445
|
+
} else {
|
|
446
|
+
this.later(outcome.retryAfter, () => { if (this.ws === ws) this.flush(); });
|
|
447
|
+
}
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (data.error === "rate_limited") {
|
|
451
|
+
// Too fast: everything on the socket goes again a little later, with the same ids.
|
|
452
|
+
this.outbox.socketClosed();
|
|
453
|
+
this.later(3000, () => { if (this.ws === ws) this.flush(); });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (data.error === "message_failed") return;
|
|
457
|
+
// invalid_message, invalid_attachment and any code this client does not know (the AsyncAPI says
|
|
458
|
+
// to treat an unknown one as invalid_message). Frames are checked in order, so the refusal is
|
|
459
|
+
// for the oldest message still waiting.
|
|
460
|
+
const refused = this.outbox.refuseOldestSending();
|
|
461
|
+
if (refused) this.fail(refused.clientMessageId);
|
|
462
|
+
this.emit(this.errorCbs, new GiovChatError("send_failed", SEND_FAILED));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private later(delay: number, run: () => void): void {
|
|
466
|
+
const timer = setTimeout(() => { this.resendTimers.delete(timer); run(); }, delay);
|
|
467
|
+
this.resendTimers.add(timer);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private settle(clientMessageId: string, message: GiovMessage | null): void {
|
|
471
|
+
const waiters = this.pending.get(clientMessageId);
|
|
472
|
+
this.pending.delete(clientMessageId);
|
|
473
|
+
for (const waiter of waiters ?? []) {
|
|
474
|
+
if (message) waiter.resolve(message);
|
|
475
|
+
else waiter.reject(new GiovChatError("send_failed", SEND_FAILED));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private fail(clientMessageId: string): void {
|
|
480
|
+
const waiters = this.pending.get(clientMessageId);
|
|
481
|
+
this.pending.delete(clientMessageId);
|
|
482
|
+
for (const waiter of waiters ?? []) waiter.reject(new GiovChatError("send_failed", SEND_FAILED));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private reconnect(generation: number): void {
|
|
486
|
+
if (!this.desired || generation !== this.generation || this.timer) return;
|
|
487
|
+
const delay = reconnectDelay(this.attempt++);
|
|
488
|
+
this.timer = setTimeout(() => {
|
|
489
|
+
this.timer = null;
|
|
490
|
+
void this.open(generation).catch((error: unknown) => {
|
|
491
|
+
if (!this.desired || generation !== this.generation) return;
|
|
492
|
+
const failure = chatError(error);
|
|
493
|
+
if (failure.code === "invalid_session") this.disconnect();
|
|
494
|
+
else this.reconnect(generation);
|
|
495
|
+
this.emit(this.errorCbs, failure);
|
|
496
|
+
});
|
|
497
|
+
}, delay);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Reconnect now instead of waiting for the backoff, e.g. when the app returns to the foreground. */
|
|
501
|
+
reconnectNow(): void {
|
|
502
|
+
if (!this.desired || this.isConnected || !this.timer) return;
|
|
503
|
+
clearTimeout(this.timer);
|
|
504
|
+
this.timer = null;
|
|
505
|
+
this.attempt = 0;
|
|
506
|
+
const generation = this.generation;
|
|
507
|
+
void this.open(generation).catch((error: unknown) => {
|
|
508
|
+
if (!this.desired || generation !== this.generation) return;
|
|
509
|
+
const failure = chatError(error);
|
|
510
|
+
if (failure.code === "invalid_session") this.disconnect();
|
|
511
|
+
else this.reconnect(generation);
|
|
512
|
+
this.emit(this.errorCbs, failure);
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Puts everything waiting in the outbox on the socket, oldest first. */
|
|
517
|
+
flush(): void {
|
|
518
|
+
const ws = this.ws;
|
|
519
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
520
|
+
for (const { frame } of this.outbox.takeQueued()) {
|
|
521
|
+
try { ws.send(frame); } catch { ws.close(); return; }
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Queues a visitor message and sends it as soon as a socket is open; returns its client message id
|
|
527
|
+
* at once. Delivery is tracked in `outbox` and confirmed through `onAck`.
|
|
528
|
+
*/
|
|
529
|
+
send(text: string, attachments: readonly string[] = []): string {
|
|
530
|
+
const entry = this.outbox.enqueue(text, attachments);
|
|
531
|
+
this.flush();
|
|
532
|
+
return entry.clientMessageId;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Sends one message and resolves with the stored copy once the server confirms it. While the
|
|
537
|
+
* connection is being restored it waits in the outbox instead of failing. Rejects only when the
|
|
538
|
+
* server refuses it or `disconnect()` is called first.
|
|
539
|
+
*/
|
|
540
|
+
async sendMessage(text: string): Promise<GiovMessage> {
|
|
541
|
+
if (!text.trim() || text.length > 2000) throw new GiovChatError("send_failed", "Messages must contain 1–2000 characters.");
|
|
542
|
+
const clientMessageId = this.outbox.enqueue(text).clientMessageId;
|
|
543
|
+
const result = new Promise<GiovMessage>((resolve, reject) => {
|
|
544
|
+
const waiters = this.pending.get(clientMessageId) ?? [];
|
|
545
|
+
waiters.push({ resolve, reject });
|
|
546
|
+
this.pending.set(clientMessageId, waiters);
|
|
547
|
+
});
|
|
548
|
+
this.flush();
|
|
549
|
+
return result;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Sends a message that failed again, in its original place in the queue. */
|
|
553
|
+
retryMessage(clientMessageId: string): boolean {
|
|
554
|
+
const retried = this.outbox.retry(clientMessageId);
|
|
555
|
+
if (retried) this.flush();
|
|
556
|
+
return retried;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** The visitor started typing (send once per burst) or stopped. */
|
|
560
|
+
sendTyping(): void { this.sendFrame({ type: "typing" }); }
|
|
561
|
+
sendTypingEnd(): void { this.sendFrame({ type: "typing_end" }); }
|
|
562
|
+
private sendFrame(frame: Record<string, unknown>): void {
|
|
563
|
+
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
|
564
|
+
try { this.ws.send(JSON.stringify(frame)); } catch { /* The next reconnect recovers. */ }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Uploads one file to this conversation and returns its URL for `send`. Reports progress (0–1)
|
|
569
|
+
* where XMLHttpRequest is available (React Native has it). Retries once with a new token on 401.
|
|
570
|
+
*/
|
|
571
|
+
async uploadAttachment(file: GiovChatPickedFile, onProgress?: (fraction: number) => void): Promise<GiovUploadResult> {
|
|
572
|
+
const session = this.session;
|
|
573
|
+
if (!session) throw new GiovChatError("disconnected", "Chat closed.");
|
|
574
|
+
const path = `${this.baseUrl}/conversations/${encodeURIComponent(session.conversationId)}/upload-visitor`;
|
|
575
|
+
// One bound for the whole upload, the 401 retry included, so a stalled network cannot hold the composer.
|
|
576
|
+
const deadline = Date.now() + UPLOAD_TIMEOUT_MS;
|
|
577
|
+
let { token } = await this.token();
|
|
578
|
+
let response = await upload(path, token, file, deadline, onProgress);
|
|
579
|
+
if (response.status === 401 && Date.now() < deadline) {
|
|
580
|
+
token = (await this.token(true)).token;
|
|
581
|
+
response = await upload(path, token, file, deadline, onProgress);
|
|
582
|
+
}
|
|
583
|
+
const data = object(response.body);
|
|
584
|
+
if (response.status < 200 || response.status >= 300 || typeof data.url !== "string") throw uploadError(response, data);
|
|
585
|
+
const access = parseAttachmentAccess(data.attachmentAccess);
|
|
586
|
+
if (access) this.emit(this.accessCbs, access);
|
|
587
|
+
const preview = sanitizePreview(data.preview);
|
|
588
|
+
return { url: data.url, filename: typeof data.filename === "string" ? data.filename : file.name, ...(preview ? { preview } : {}) };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Registers this device for visitor push notifications about this conversation. Until the server
|
|
593
|
+
* supports visitor push it resolves `{ registered: false, reason: "unsupported" }` and never throws.
|
|
594
|
+
*/
|
|
595
|
+
async registerPushToken(token: string, platform: GiovPushPlatform, options: GiovPushOptions = {}): Promise<GiovPushRegistration> {
|
|
596
|
+
if (this.pushUnsupported) return { registered: false, reason: "unsupported" };
|
|
597
|
+
const session = this.session;
|
|
598
|
+
if (!session) return { registered: false, reason: "no_conversation" };
|
|
599
|
+
if (!token.trim() || token.length > 4096 || (platform !== "ios" && platform !== "android")) return { registered: false, reason: "failed" };
|
|
600
|
+
const locale = options.locale ?? deviceLocale();
|
|
601
|
+
try {
|
|
602
|
+
const response = await this.pushRequest(session, "POST", { token, platform,
|
|
603
|
+
...(options.environment ? { environment: options.environment } : {}), ...(options.appId ? { appId: options.appId } : {}),
|
|
604
|
+
...(PUSH_LOCALE.test(locale) ? { locale } : {}) });
|
|
605
|
+
if (response.status === 404 || response.status === 405 || response.status === 501) {
|
|
606
|
+
this.pushUnsupported = true;
|
|
607
|
+
return { registered: false, reason: "unsupported" };
|
|
608
|
+
}
|
|
609
|
+
return response.ok ? { registered: true } : { registered: false, reason: "failed" };
|
|
610
|
+
} catch {
|
|
611
|
+
return { registered: false, reason: "failed" };
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Stops push notifications about this conversation on this device (sign-out, notifications turned
|
|
617
|
+
* off). Resolves true when the server confirmed; never throws. Without a live connection, pass the
|
|
618
|
+
* stored `session`: a fresh visitor token is fetched for it.
|
|
619
|
+
*/
|
|
620
|
+
async unregisterPushToken(token: string, session?: GiovChatSession | null): Promise<boolean> {
|
|
621
|
+
if (this.pushUnsupported || !token.trim() || token.length > 4096) return false;
|
|
622
|
+
if (this.session) {
|
|
623
|
+
try { return (await this.pushRequest(this.session, "DELETE", { token })).ok; } catch { return false; }
|
|
624
|
+
}
|
|
625
|
+
return session ? unregisterPushToken(this.baseUrl, this.options.tenantKey, session, token) : false;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** One push-token request with the visitor token, renewed once on 401 (protocol "Tokens"). */
|
|
629
|
+
private async pushRequest(session: GiovChatSession, method: "POST" | "DELETE", body: Record<string, unknown>): Promise<Response> {
|
|
630
|
+
const send = async (visitorToken: string) => pushFetch(this.baseUrl, session, method, visitorToken, body);
|
|
631
|
+
const response = await send((await this.token()).token);
|
|
632
|
+
return response.status === 401 ? send((await this.token(true)).token) : response;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private rejectPending(): void {
|
|
636
|
+
for (const waiters of this.pending.values()) {
|
|
637
|
+
for (const waiter of waiters) waiter.reject(new GiovChatError("send_failed", "Delivery could not be confirmed. Check the conversation before sending again."));
|
|
638
|
+
}
|
|
639
|
+
this.pending.clear();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Closes the connection. Unsent messages stay in the outbox; promises from sendMessage are rejected. */
|
|
643
|
+
disconnect(): void {
|
|
644
|
+
this.rejectPending();
|
|
645
|
+
this.teardown();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private teardown(): void {
|
|
649
|
+
this.desired = false;
|
|
650
|
+
this.generation++;
|
|
651
|
+
this.controller?.abort();
|
|
652
|
+
this.controller = null;
|
|
653
|
+
this.cancelHandshake?.();
|
|
654
|
+
this.cancelHandshake = null;
|
|
655
|
+
if (this.timer) clearTimeout(this.timer);
|
|
656
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
657
|
+
if (this.watchdog) clearInterval(this.watchdog);
|
|
658
|
+
for (const timer of this.resendTimers) clearTimeout(timer);
|
|
659
|
+
this.resendTimers.clear();
|
|
660
|
+
this.timer = null;
|
|
661
|
+
this.heartbeat = null;
|
|
662
|
+
this.watchdog = null;
|
|
663
|
+
const ws = this.ws;
|
|
664
|
+
this.ws = null;
|
|
665
|
+
if (ws) {
|
|
666
|
+
ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null;
|
|
667
|
+
ws.close();
|
|
668
|
+
}
|
|
669
|
+
// Unsent messages stay in the outbox; a later connection sends them.
|
|
670
|
+
this.outbox.socketClosed();
|
|
671
|
+
this.session = null;
|
|
672
|
+
this.widgetToken = null;
|
|
673
|
+
this.tokenRequest = null;
|
|
674
|
+
this.emit(this.statusCbs, false);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
get isConnected(): boolean { return this.ws !== null && this.ws.readyState === WebSocket.OPEN; }
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** A push-token request; it names the SDK so the dashboard can show which app versions receive pushes. */
|
|
681
|
+
function pushFetch(baseUrl: string, session: GiovChatSession, method: "POST" | "DELETE", visitorToken: string, body: Record<string, unknown>): Promise<Response> {
|
|
682
|
+
return fetch(`${baseUrl}/conversations/${encodeURIComponent(session.conversationId)}${PUSH_TOKEN_PATH}`, {
|
|
683
|
+
method, headers: { "Content-Type": "application/json", Authorization: `Bearer ${visitorToken}`, ...CLIENT_HEADER }, body: JSON.stringify(body),
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Stops push for a stored conversation without a connection (the chat is closed or hidden): a fresh
|
|
689
|
+
* visitor token from the session secret, then `DELETE /push-token`. Never throws.
|
|
690
|
+
*/
|
|
691
|
+
export async function unregisterPushToken(baseUrl: string, tenantKey: string, session: GiovChatSession, token: string): Promise<boolean> {
|
|
692
|
+
if (!token.trim() || token.length > 4096) return false;
|
|
693
|
+
try {
|
|
694
|
+
const { token: visitorToken } = await fetchWidgetToken(baseUrl, tenantKey, session);
|
|
695
|
+
return (await pushFetch(normalizeBaseUrl(baseUrl), session, "DELETE", visitorToken, { token })).ok;
|
|
696
|
+
} catch {
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** The whole upload, retry included, gives up after this long. */
|
|
702
|
+
export const UPLOAD_TIMEOUT_MS = 120_000;
|
|
703
|
+
|
|
704
|
+
type UploadResponse = { status: number; body: unknown; retryAfter?: number; timedOut?: boolean };
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* An upload refusal by status: 404 the conversation is gone, 401/403 the session is gone, 429 rate
|
|
708
|
+
* limited (with Retry-After), 5xx the server, any other 4xx the file itself (type, size, policy).
|
|
709
|
+
*/
|
|
710
|
+
function uploadError(response: UploadResponse, data: Record<string, unknown>): GiovChatError {
|
|
711
|
+
const { status } = response;
|
|
712
|
+
const message = typeof data.message === "string" ? data.message : "File upload failed.";
|
|
713
|
+
if (status === 0) return response.timedOut ? new GiovChatError("timeout", "File upload timed out.") : new GiovChatError("network", "Unable to reach chat. Please try again.");
|
|
714
|
+
if (status === 404 || status === 409) return new GiovChatError("conversation_closed", "This conversation has ended.", status);
|
|
715
|
+
if (status === 401 || status === 403) return new GiovChatError("invalid_session", "Chat session expired. Please start a new chat.", status);
|
|
716
|
+
if (status === 429) return new GiovChatError("rate_limited", "Too many requests. Please wait a moment.", status, response.retryAfter);
|
|
717
|
+
if (status >= 500) return new GiovChatError("server", message, status, response.retryAfter);
|
|
718
|
+
return new GiovChatError("upload_failed", message, status);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** One multipart upload, through XMLHttpRequest for progress when it exists, else fetch; abandoned at `deadline`. */
|
|
722
|
+
function upload(url: string, token: string, file: GiovChatPickedFile, deadline: number, onProgress?: (fraction: number) => void): Promise<UploadResponse> {
|
|
723
|
+
const form = buildUploadForm(file);
|
|
724
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
725
|
+
const remaining = Math.max(0, deadline - Date.now());
|
|
726
|
+
if (typeof XMLHttpRequest === "undefined") {
|
|
727
|
+
const controller = new AbortController();
|
|
728
|
+
let timedOut = false;
|
|
729
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, remaining);
|
|
730
|
+
return fetch(url, { method: "POST", headers, body: form, signal: controller.signal })
|
|
731
|
+
.then(async (response) => ({ status: response.status, body: await response.json().catch(() => null),
|
|
732
|
+
retryAfter: retryAfterSeconds(response.headers?.get?.("Retry-After")) }))
|
|
733
|
+
.catch(() => ({ status: 0, body: null, timedOut }))
|
|
734
|
+
.finally(() => clearTimeout(timer));
|
|
735
|
+
}
|
|
736
|
+
return new Promise((resolve) => {
|
|
737
|
+
const xhr = new XMLHttpRequest();
|
|
738
|
+
let timedOut = false;
|
|
739
|
+
// xhr.timeout alone is not enough: some stacks never fire it while bytes trickle out.
|
|
740
|
+
const timer = setTimeout(() => { timedOut = true; xhr.abort(); }, remaining);
|
|
741
|
+
const done = (value: UploadResponse) => { clearTimeout(timer); resolve(value); };
|
|
742
|
+
xhr.open("POST", url);
|
|
743
|
+
for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value);
|
|
744
|
+
xhr.timeout = remaining;
|
|
745
|
+
if (onProgress && xhr.upload) {
|
|
746
|
+
xhr.upload.onprogress = (event) => { if (event.lengthComputable && event.total > 0) onProgress(Math.min(1, event.loaded / event.total)); };
|
|
747
|
+
}
|
|
748
|
+
xhr.onload = () => {
|
|
749
|
+
let body: unknown = null;
|
|
750
|
+
try { body = JSON.parse(xhr.responseText); } catch { /* Not JSON. */ }
|
|
751
|
+
if (xhr.status >= 200 && xhr.status < 300) onProgress?.(1);
|
|
752
|
+
done({ status: xhr.status, body, retryAfter: retryAfterSeconds(xhr.getResponseHeader?.("Retry-After")) });
|
|
753
|
+
};
|
|
754
|
+
xhr.ontimeout = () => { timedOut = true; done({ status: 0, body: null, timedOut }); };
|
|
755
|
+
xhr.onerror = xhr.onabort = () => done({ status: 0, body: null, timedOut });
|
|
756
|
+
xhr.send(form);
|
|
757
|
+
});
|
|
758
|
+
}
|