import { ChannelConfig, ChannelConversation, ChannelConversationResponse, } from 'api/api.types' import ConversationConnector from 'api/conversation-connector' import ApiError from 'api/errors/seamly-api-error' import SeamlyConfigurationError from 'api/errors/seamly-configuration-error' import SeamlyGeneralError from 'api/errors/seamly-general-error' import SeamlySessionExpiredError from 'api/errors/seamly-session-expired-error' import SeamlyUnauthorizedError from 'api/errors/seamly-unauthorized-error' import type { AccountConfigContext, ApiConfig, Context, ContextOutgoing, LayoutMode, } from 'config.types' import type { MessageUpload } from 'domains/store/store.types' import debug from 'lib/debug' import { objectStore } from 'lib/store/index' import sessionStorageProvider from 'lib/store/providers/session-storage' import { sourceTypes } from 'ui/utils/seamly-utils' import { buildPayload, fetchApi, getTimeZone } from './utils' declare let PACKAGE_NAME: string declare let PACKAGE_VERSION: string const TRANSLATIONS_VERSION = 4 const DOMAIN = 'api.seamly-app.com' type DefaultURLSType = Record> const log = debug('seamly') export type InitialChannelConversation = Omit< ChannelConversation, 'accessToken' | 'channelTopic' > export default class API { #ready: boolean connected: boolean #externalId?: string #conversationAuthToken?: string #layoutMode: LayoutMode = 'window' #config: ApiConfig & { context: Context } #conversationStatus: ChannelConversation['status'] = 'new' URLS: DefaultURLSType = {} store: { get(_key: string): string | object set(_key: string, _value: unknown): string delete(_key: string): string } connectionInfo: { apiKey: string; domain: string; secure: boolean } configReady: boolean userResponded: boolean locale: string = '' conversation: ConversationConnector = new ConversationConnector() constructor({ layoutMode, namespace, config, context, }: { layoutMode: LayoutMode namespace: string config: ApiConfig context: Context }) { this.store = objectStore( `${namespace}.connection${ context.userLocale ? `.${context.userLocale}` : '' }`, config.storageProvider || sessionStorageProvider, ) this.connectionInfo = { apiKey: config.key, domain: config.domain || DOMAIN, secure: config.secure !== false ? config.secure || true : false, } this.#config = { ...config, sendEnvironment: config.sendEnvironment ?? true, context: { ...context, channelName: context.channelName || 'web', }, } this.#ready = false this.connected = false this.configReady = false if (config.externalId) this.#externalId = config.externalId this.#layoutMode = layoutMode this.userResponded = false this.URLS = { translations: { href: `/channels/api/v3/client/${this.connectionInfo.apiKey}/translations/{version}/{locale}.json`, templated: true, }, config: { href: `/channels/api/v3/client/${this.connectionInfo.apiKey}/configs`, templated: true, }, } // We want to reconnect whenever the page is loaded from cache (bfcache). // Older browsers don't support 'pageshow' and 'bfcache' so this will be ignored and work as usual. window.addEventListener('pageshow', (event) => { if (event.persisted && this.connected) { this.connect() } }) } getAccessToken(): string { const accessToken = this.store.get('accessToken') as string return accessToken } setAccessToken(accessToken: string) { this.store.set('accessToken', accessToken) } getConversationUrl(): string { const conversationUrl = this.store.get('conversationUrl') as string return conversationUrl } setConversationUrl(url: { href?: string }) { this.store.set('conversationUrl', url?.href) } hasConversation(): boolean { return !!this.getConversationUrl() } getChannelTopic() { const channelTopic = (this.store.get('channelTopic') || this.store.get('channelName')) as string // The `channelName` fallback is needed for seamless client upgrades. // TODO: Remove when all clients have been upgraded past v20. return channelTopic } getLocale = (locale: string): string => locale || this.locale #updateUrls({ _links: { self: _, ...links } }: { _links: DefaultURLSType }) { this.URLS = { ...this.URLS, ...links, } } clearStore() { this.store.delete('accessToken') this.store.delete('conversationUrl') // TODO: Remove `channelName` when all clients have been upgraded past v20. this.store.delete('channelName') this.store.delete('channelTopic') } #getUrlPrefix(protocol: string) { const realProtocol = this.connectionInfo.secure ? `${protocol}s` : protocol return `${realProtocol}://${this.connectionInfo.domain}` } async getTranslations(locale: string): Promise> { try { if (!this.URLS.translations?.href) { throw new SeamlyConfigurationError() } const url = `${this.#getUrlPrefix('http')}${this.URLS.translations.href}` .replace('{version}', String(TRANSLATIONS_VERSION)) .replace('{locale}', this.getLocale(locale)) const response = await fetchApi(url, { method: 'GET', }) const body = await response.json() return body.translations } catch (error: any) { if (error.status >= 500) { throw new SeamlyGeneralError(error) } throw new ApiError(error) } } getContext(context: ContextOutgoing): ContextOutgoing | undefined { const { source, userLocale, variables } = context if (source) { if (typeof source !== 'string') { throw new Error('Source must be a string') } } if (userLocale) { if (typeof userLocale !== 'string') { throw new Error('Locale must be a string') } this.#maybeSetSourceForUserLocaleSwitch(context, userLocale) } if (variables) { if (typeof variables !== 'object') { throw new Error('Variables must be an object') } } // If we have empty context don't send context message if (Object.keys(context).length === 0 && context.constructor === Object) { return undefined } const localUserLocale = this.#config.context?.userLocale return { // Only send userLocale if explicitly set in the config (should be overridable by provided context) ...(localUserLocale ? { userLocale: localUserLocale } : {}), ...context, } } async downloadFile(fileId: string, filename: string) { try { const response = await fetchApi( `${this.#getUrlPrefix('http')}${this.URLS.uploads.href}/${fileId}`, { method: 'GET', headers: { Authorization: `Bearer ${this.getAccessToken()}`, }, }, ) // Build and click an anchor tag to allow setting the filename (through the `download` attribute). // However, some versions of Android browsers and/or WebViews do not support this attribute, so // in that case ignore the property (to prevent JavaScript errors). const anchor = document.createElement('a') const url = URL.createObjectURL(await response.blob()) anchor.href = url if (typeof anchor.download !== 'undefined') { anchor.download = filename } anchor.click() // Cleanup the reference to the blob after 60 seconds to prevent memory leaks. Strictly speaking // this is not necessary, because navigating away will also clean up the reference, but since we're // in a running conversation this might take a while. The 60 seconds is arbitrary. setTimeout(() => { URL.revokeObjectURL(url) }, 60000) } catch (_) { // We will not throw an error here. The failed request will already be logged. } } uploadFile( file: string | Blob, progressCallback: (_progress: number) => void, successCallback: (_response: MessageUpload['payload']) => void, errorCallback: (_response: Record) => void, ) { const formData = new FormData() formData.append('upload', file) const xhr = new XMLHttpRequest() xhr.open('POST', `${this.#getUrlPrefix('http')}${this.URLS.uploads.href}`) xhr.setRequestHeader('Authorization', `Bearer ${this.getAccessToken()}`) xhr.upload.onprogress = (event) => { if (typeof progressCallback === 'function') { const percent = Math.ceil((event.loaded / event.total) * 100) progressCallback(percent) } } xhr.onloadend = () => { // status is set to 0 when upload is aborted. if (xhr.status === 0) return if (xhr.status === 200 || xhr.status === 201) { if (successCallback) { try { successCallback(JSON.parse(xhr.response)) } catch (_) { successCallback(xhr.response) } return } } if (errorCallback) { try { errorCallback(JSON.parse(xhr.response)) } catch (_) { errorCallback(xhr.response) } } else { throw new Error(xhr.response) } } xhr.send(formData) return xhr } async createConversation() { try { if (typeof this.#config?.getConversationAuthToken === 'function') { this.#conversationAuthToken = await this.#config.getConversationAuthToken() } const response = await fetchApi( `${this.#getUrlPrefix('http')}${this.URLS.conversations?.href}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ externalId: this.#conversationAuthToken ? undefined : this.#externalId, token: this.#conversationAuthToken, }), }, ) const body: ChannelConversationResponse = await response.json() const { conversation } = body const initialState = { ...conversation } this.setAccessToken(conversation.accessToken) this.#updateUrls(body) if (this.URLS.conversation) this.setConversationUrl(this.URLS.conversation) this.locale = conversation.context.userLocale this.userResponded = conversation.userResponded this.#conversationStatus = conversation.status return initialState } catch (error: any) { if (error.status >= 500) { throw new SeamlyGeneralError(error) } if (error.status === 400) { throw new SeamlyUnauthorizedError(error) } if (error.status === 404) { throw new SeamlyConfigurationError(error) } throw error } } async getConfig(): Promise { try { const context: AccountConfigContext = { channelName: this.#config.context.channelName, contentLocale: this.#config.context.contentLocale, environment: this.#config.sendEnvironment === true ? this.getEnvironment() : this.#config.sendEnvironment, userLocale: this.#config.context.userLocale, variables: this.#config.context.variables, } const response = await fetchApi( `${this.#getUrlPrefix('http')}${this.URLS.config?.href}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ context }), }, ) const body = await response.json() this.#updateUrls(body) this.configReady = true return body.config } catch (error: any) { if (error.status === 404) { throw new SeamlyConfigurationError(error) } if (error.status >= 500) { throw new SeamlyGeneralError(error) } throw error } } async fetchConversation(): Promise { if (!this.hasConversation()) { return null } try { const response = await fetchApi( `${this.#getUrlPrefix('http')}${this.getConversationUrl()}`, { method: 'GET', headers: { Authorization: `Bearer ${this.getAccessToken()}`, }, }, ) const body: ChannelConversationResponse = await response.json() this.#conversationStatus = body.conversation.status this.#updateUrls(body) return body.conversation } catch (error: any) { if (error.status === 401) { throw new SeamlyUnauthorizedError(error) } if (error.status === 404) { throw new SeamlySessionExpiredError(error) } if (error.status >= 500) { throw new SeamlyGeneralError(error) } throw error } } async getConversation() { if (!this.hasConversation()) { return null } return this.fetchConversation() } async getConversationIntitialState(): Promise { return this.fetchConversation() } async disconnect() { if (this.conversation?.disconnect) { this.conversation.disconnect() } this.connected = false this.configReady = false } async connect() { this.connected = false const conversationInitialState = !this.hasConversation() ? await this.createConversation() : undefined if (this.URLS.socket) { this.conversation.connect( `${this.#getUrlPrefix('ws')}${this.URLS.socket.href}`, this.#config.context.channelName || '', this.getChannelTopic(), this.getAccessToken(), ) this.conversation.onConnection(({ connected, ready }) => { this.connected = connected this.#ready = ready }) this.send('context', this.#getContextForConnect()) } return conversationInitialState } send(command: string, payload: any = undefined) { if (!this.connected || !this.#ready) { // Wait for connection to be made this.conversation?.onConnection(({ connected, ready }) => { this.connected = connected this.#ready = ready if (ready) { this.send(command, payload) return true } return false }) return } log('[SEND]', command, payload) this.conversation.pushToChannel(command, buildPayload(command, payload)) } sendContext(context: ContextOutgoing) { const formattedContext = this.getContext(context) if (formattedContext) { this.send('context', formattedContext) } } #getContextForConnect() { const environment = this.#config.sendEnvironment === true ? this.getEnvironment() : undefined const source = typeof this.#config.context?.source === 'string' ? this.#config.context?.source : undefined const variables = this.#config.context?.variables // Do not send locales for conversations with status 'started' // This prevents us from accidentally turning translations on/off const contentLocale = this.#conversationStatus !== 'started' ? this.#config.context?.contentLocale : undefined const userLocale = this.#conversationStatus !== 'started' ? this.#config.context?.userLocale : undefined return { contentLocale, environment, source, userLocale, variables, } } #maybeSetSourceForUserLocaleSwitch( context: ContextOutgoing, userLocale: string, ) { if (!context.source && userLocale != this.#config.context?.userLocale) { context.source = sourceTypes.windowApi } } getEnvironment() { return { clientName: PACKAGE_NAME, clientVariant: this.#layoutMode, clientVersion: PACKAGE_VERSION, currentUrl: window.location.toString(), screenResolution: `${window.screen.width}x${window.screen.height}`, timezone: getTimeZone(), userAgent: navigator.userAgent, preferredLocale: navigator.language, } } }