├── .babelrc ├── .eslintignore ├── .eslintrc ├── .github ├── CODEOWNERS ├── docs │ ├── COMMON_ISSUES.md │ ├── NpmPublishDocumentation.md │ └── ReactNativeSupport.md └── workflows │ ├── node.js.yml │ └── publish.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── NOTICE ├── README.md ├── package-lock.json ├── package.json ├── src ├── chat.js ├── chat.spec.js ├── client │ ├── aws-sdk-connectparticipant.js │ ├── client.js │ └── client.spec.js ├── config │ └── csmConfig.js ├── constants.js ├── core │ ├── MessageReceiptsUtil.js │ ├── MessageReceiptsUtil.spec.js │ ├── chatArgsValidator.js │ ├── chatArgsValidator.spec.js │ ├── chatController.js │ ├── chatController.spec.js │ ├── chatSession.js │ ├── chatSession.spec.js │ ├── connectionHelpers │ │ ├── LpcConnectionHelper.js │ │ ├── LpcConnectionHelper.spec.js │ │ ├── baseConnectionHelper.js │ │ ├── baseConnectionHelper.spec.js │ │ ├── connectionDetailsProvider.js │ │ └── connectionDetailsProvider.spec.js │ ├── eventbus.js │ └── exceptions.js ├── globalConfig.js ├── globalConfig.spec.js ├── index.d.ts ├── index.js ├── index.spec.js ├── lib │ ├── amazon-connect-websocket-manager.js │ ├── amazon-connect-websocket-manager.js.map │ ├── connect-csm-worker.js │ └── connect-csm.js ├── log.js ├── metadata.js ├── service │ ├── csmService.js │ └── csmService.spec.js ├── utils.js └── utils.spec.js ├── test ├── jestSetup.js └── polyfills.js ├── tsconfig.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "esmodules": true 8 | } 9 | } 10 | ] 11 | ] 12 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /src/client/** 2 | /src/lib/** 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": "off", 4 | "eqeqeq": ["error", "always"], 5 | "semi": ["error", "always"], 6 | "indent": "warn" 7 | }, 8 | "env": { 9 | "es6": true, 10 | "browser": true, 11 | "node": true, 12 | "jest": true 13 | }, 14 | "extends": [ 15 | "eslint:recommended", 16 | "prettier" 17 | ], 18 | "parser": "@babel/eslint-parser", 19 | "plugins": ["jest"], 20 | "globals": { 21 | "module": true, 22 | "require": true, 23 | "process": true, 24 | "connect": true, 25 | "csm": true 26 | }, 27 | "parserOptions": { 28 | "ecmaVersion": 2017, 29 | "sourceType": "module" 30 | } 31 | } -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Last updated: September 2023 2 | # Team: https://github.com/orgs/amazon-connect/teams/blitz 3 | 4 | * @amazon-connect/blitz 5 | -------------------------------------------------------------------------------- /.github/docs/COMMON_ISSUES.md: -------------------------------------------------------------------------------- 1 | # Common ChatJS Issues 2 | 3 | List of common ChatJS issue/bugs and solutions 4 | 5 | ## 001 - Network or WebSocket disconnected - missing Agent messages 6 | 7 | During a chat session, the end-customer using a chat application loses network/websocket connection. They re-gain connection shortly after, but Agent messages are failing to render in chat 8 | 9 | Screenshot 2024-03-02 at 5 57 50 PM 10 | 11 | If the **end-customer chat UI** loses websocket/network connection, it must: 12 | 13 | 1. Re-establish the websocket connection (to receive future incoming messages again) 14 | 2. Make a [chatSession.getTranscript](https://github.com/amazon-connect/amazon-connect-chatjs?tab=readme-ov-file#chatsessiongettranscript) (API: [ GetTranscript](https://docs.aws.amazon.com/connect-participant/latest/APIReference/API_GetTranscript.html)) request (to retrieve all missing messages that were sent while end-customer was disconnected) 15 | 16 | If the agent sends a message while the end-customer chat UI is disconnected, it is successfully stored in the Connect backend (CCP is working as expected, messages are all recorded in transcript), but the client's device was unable to receive it. When the client reconnects to the websocket connection, there is a gap in messages. Future incoming messages will appear again from the websocket, but the gap messages are still missing unless the code explicitly makes a [GetTranscript](https://docs.aws.amazon.com/connect-participant/latest/APIReference/API_GetTranscript.html) call. 17 | 18 | ### Solution 19 | 20 | The [chatSession.onConnectionEstablished](https://github.com/amazon-connect/amazon-connect-chatjs?tab=readme-ov-file#chatsessiononconnectionestablished) event handler may help, which is triggered when websocket re-connects. ChatJS has built-in heartbeat and retry logic for the websocket connection. Since ChatJS is not storing the transcript though, **Custom chat UI** code must manually fetch the transcript again 21 | 22 | ```js 23 | import "amazon-connect-chatjs"; 24 | 25 | const chatSession = connect.ChatSession.create({ 26 | chatDetails: { 27 | ContactId: "abc", 28 | ParticipantId: "cde", 29 | ParticipantToken: "efg", 30 | }, 31 | type: "CUSTOMER", 32 | options: { region: "us-west-2" }, 33 | }); 34 | 35 | // Triggered when the websocket reconnects 36 | chatSession.onConnectionEstablished(() => { 37 | chatSession.getTranscript({ 38 | scanDirection: "BACKWARD", 39 | sortOrder: "ASCENDING", 40 | maxResults: 15, 41 | // nextToken?: nextToken - OPTIONAL, for pagination 42 | }) 43 | .then((response) => { 44 | const { initialContactId, nextToken, transcript } = response.data; 45 | // ... 46 | }) 47 | .catch(() => {}) 48 | }); 49 | ``` 50 | 51 | ```js 52 | function loadLatestTranscript(args) { 53 | // Documentation: https://github.com/amazon-connect/amazon-connect-chatjs?tab=readme-ov-file#chatsessiongettranscript 54 | return chatSession.getTranscript({ 55 | scanDirection: "BACKWARD", 56 | sortOrder: "ASCENDING", 57 | maxResults: 15, 58 | // nextToken?: nextToken - OPTIONAL, for pagination 59 | }) 60 | .then((response) => { 61 | const { initialContactId, nextToken, transcript } = response.data; 62 | 63 | const exampleMessageObj = transcript[0]; 64 | const { 65 | DisplayName, 66 | ParticipantId, 67 | ParticipantRole, // CUSTOMER, AGENT, SUPERVISOR, SYSTEM 68 | Content, 69 | ContentType, 70 | Id, 71 | Type, 72 | AbsoluteTime, // sentTime = new Date(item.AbsoluteTime).getTime() / 1000 73 | MessageMetadata, // { Receipts: [{ RecipientParticipantId: "asdf" }] } 74 | Attachments, 75 | RelatedContactid, 76 | } = exampleMessageObj; 77 | 78 | return transcript // TODO - store the new transcript somewhere 79 | }) 80 | .catch((err) => { 81 | console.log("CustomerUI", "ChatSession", "transcript fetch error: ", err); 82 | }); 83 | } 84 | ``` 85 | 86 | > Additionally, it's possible to refer to the open source implementation: https://github.com/amazon-connect/amazon-connect-chat-interface/blob/c88f854073fe6dd45546585c3bfa363d3659d73f/src/components/Chat/ChatSession.js#L408 87 | -------------------------------------------------------------------------------- /.github/docs/NpmPublishDocumentation.md: -------------------------------------------------------------------------------- 1 | 2 | # Npm Package Publish 3 | 4 | Documentation for publishing to npmjs.org: https://docs.npmjs.com/creating-and-publishing-scoped-public-packages 5 | 6 | ## [MANUAL] Publish with npm cli commands 7 | 8 | 1. Create a GitHub release: https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository 9 | 10 | - Head to https://github.com/amazon-connect/amazon-connect-chatjs/releases 11 | - Draft new release 12 | - Choose tag, enter new semver 13 | - Click, "create tag on publish" 14 | - Publish the release 15 | 16 | 2. Publish the package to npm 17 | 18 | ```sh 19 | git clone https://github.com/amazon-connect/amazon-connect-chatjs.git 20 | cd amazon-connect-chatjs 21 | npm i 22 | npm run release 23 | git status 24 | 25 | npm login 26 | npm publish --dry-run 27 | npm publish --access=public 28 | ``` 29 | 30 | 3. View release: https://www.npmjs.com/package/amazon-connect/amazon-connect-chatjs 31 | 32 | ## [AUTOMATION] GitHub Action Npm Publish Workflow 33 | 34 | Steps to configure/update the `npm publish` [workflow](https://github.com/amazon-connect/amazon-connect-chatjs/blob/master/.github/workflows/publish.yml) for automated `npm publish`. 35 | 36 | ## Setup ⚙️ 37 | 38 | > Note: must have NPM_TOKEN set in GitHub Secrets, with `automation` permissions 39 | 40 | By creating a GitHub deployment environment, you can set environment variables and specify users to approve. 41 | 42 | 43 | Edit the secrets 44 | 45 | 46 | - [npmjs.org] Become admin of the npm package 47 | - [npmjs.org] Create a granular NPM_TOKEN to publish `amazon-connect-chatjs` [[docs](https://docs.npmjs.com/creating-and-viewing-access-tokens)] 48 | - [github.com] Become admin of the GitHub repository 49 | - [github.com] Create/update `release` environment, with required reviewers in repository settings [[docs](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment)] 50 | - [github.com] Add `NPM_TOKEN` under the secrets for the `release` environment. 51 | - [github.com] Add/remove admin users to the environment (eg. /settings/environments/873739246/edit) 52 | 53 | #### Usage 54 | 55 | Creating a release and triggering the `npm publish` [workflow](https://github.com/amazon-connect/amazon-connect-chatjs/blob/master/.github/workflows/publish.yml). 56 | 57 | 1. Create a GitHub release: https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository 58 | 59 | - Head to https://github.com/amazon-connect/amazon-connect-chatjs/releases 60 | - Draft new release 61 | - Choose tag, enter new semver 62 | - Click, "create tag on publish" 63 | - Edit the title/description 64 | - Publish the release 65 | 66 | 67 | AdminDraftsARelease 68 | 69 | 70 | 2. Workflow is triggered on release (or you can trigger with manual `workflow_dispatch`): 71 | 72 | - Head to https://github.com/amazon-connect/amazon-connect-chatjs/actions 73 | - Expand the pending Npm Publish workflow 74 | 75 | 76 | View_Actions 77 | 78 | 79 | - Review the workflow 80 | 81 | 82 | AdminEnabledDryRunAccessingEnvironment 83 | 84 | 85 | 3. Run the dry-run workflow: 86 | 87 | - Approve the workflow 88 | 89 | 90 | AdminApprovesDryRun 91 | 92 | 93 | - View Dry-run workflow results 94 | 95 | 96 | DryRunComplete 97 | 98 | 99 | 4. Run the publish workflow: 100 | 101 | - Approve the publish workflow 102 | 103 | 104 | PublishLiveIsPending 105 | 106 | 107 | 108 | AdminApprovesPublishLive 109 | 110 | 111 | - View publish workflow results 112 | 113 | 114 | NpmPublishSuceeds 115 | 116 | 117 | 5. View the live updated npm package 118 | 119 | 120 | Published 121 | 122 | 123 | 124 | Release is live 125 | -------------------------------------------------------------------------------- /.github/docs/ReactNativeSupport.md: -------------------------------------------------------------------------------- 1 | # React Native Support 2 | 3 | > ‼️ Additional configuration is required to support ChatJS in React Native applications - see ["Configuration"](#configuration) 4 | 5 | 6 | ## Demo 7 | 8 | A demo application implementing basic ChatJS functionality is available in the ui-examples repository: [connectReactNativeChat](https://github.com/amazon-connect/amazon-connect-chat-ui-examples/tree/master/connectReactNativeChat) 9 | 10 | 11 | ## Client Side Metrics (CSM) Support 12 | 13 | > ⚠️ NOT CURRENTLY SUPPORTED - For more details please refer to the [tracking issue](https://github.com/amazon-connect/amazon-connect-chatjs/issues/171) 14 | 15 | The out-of-box ChatJS client side metrics are not currently supported in React Native. ChatJS is officially supported for browser environments, and may run into issues accessing the `document` DOM API. 16 | 17 | You can safely disable CSM without affecting other behavior: 18 | 19 | ```diff 20 | this.session = connect.ChatSession.create({ 21 | chatDetails: startChatDetails, 22 | + disableCSM: true, 23 | type: 'CUSTOMER', 24 | options: { region }, 25 | }); 26 | ``` 27 | 28 | ## Configuration 29 | 30 | Use `amazon-connect-chatjs@^1.5.0` and customize the global configuration: 31 | 32 | ``` 33 | connect.ChatSession.setGlobalConfig({ 34 | webSocketManagerConfig: { 35 | isNetworkOnline: () => true, // default: () => navigator.onLine 36 | } 37 | }); 38 | ``` 39 | 40 | To further customize the `isNetworkOnline` input, see the options below: 41 | 42 | #### Override Browser Network Health Check 43 | 44 | If running ChatJS in mobile React Native environment, override the default network online check: 45 | 46 | > `amazon-connect-websocket-manager.js` depencency will use `navigator.onLine`. Legacy browsers will always return `true`, but unsupported or mobile runtime will return `null/undefined`. 47 | 48 | ```js 49 | /** 50 | * `amazon-connect-websocket-manager.js` depencency will use `navigator.onLine` 51 | * Unsupported or mobile runtime will return `null/undefined` - preventing websocket connections 52 | * Legacy browsers will always return `true` [ref: caniuse.com/netinfo] 53 | */ 54 | const customNetworkStatusUtil = () => { 55 | if (navigator && navigator.hasOwnProperty("onLine")) { 56 | return navigator.onLine; 57 | } 58 | 59 | return true; 60 | } 61 | 62 | connect.ChatSession.setGlobalConfig({ 63 | webSocketManagerConfig: { 64 | isNetworkOnline: customNetworkStatusUtil, 65 | } 66 | }); 67 | ``` 68 | 69 | #### Custom Network Health Check 70 | 71 | Extending this, device-native network health checks can be used for React Native applications. 72 | 73 | 1. First, install the `useNetInfo` react hook: 74 | 75 | ```sh 76 | $ npm install --save @react-native-community/netinfo 77 | # source: https://github.com/react-native-netinfo/react-native-netinfo 78 | ``` 79 | 80 | 2. Make sure to update permissions, Android requires the following line in `AndroidManifest.xml`: (for SDK version after 23) 81 | 82 | ```xml 83 | 86 | ``` 87 | 88 | 3. Set up the network event listener, and pass custom function to `setGlobalConfig`: 89 | 90 | > Note: To configure `WebSocketManager`, `setGlobalConfig` must be invoked 91 | 92 | ```js 93 | import ChatSession from "./ChatSession"; 94 | import NetInfo from "@react-native-community/netinfo"; 95 | import "amazon-connect-chatjs"; // ^1.5.0 - imports global "connect" object 96 | 97 | let isOnline = true; 98 | 99 | /** 100 | * By default, `isNetworkOnline` will be invoked every 250ms 101 | * Should only current status, and not make `NetInfo.fetch()` call 102 | * 103 | * @return {boolean} returns true if currently connected to network 104 | */ 105 | const customNetworkStatusUtil = () => isOnline; 106 | 107 | const ReactNativeChatComponent = (props) => { 108 | 109 | /** 110 | * Network event listener native to device 111 | * Will update `isOnline` value asynchronously whenever network calls are made 112 | */ 113 | const unsubscribeNetworkEventListener = NetInfo.addEventListener(state => { 114 | isOnline = state.isConnected; 115 | }); 116 | 117 | useEffect(() => { 118 | return unsubscribeNetworkEventListener(); 119 | }, []); 120 | 121 | const initializeChatJS = () => { 122 | // To configure WebSocketManager, setGlobalConfig must be invoked 123 | connect.ChatSession.setGlobalConfig({ 124 | // ... 125 | webSocketManagerConfig: { 126 | isNetworkOnline: customNetworkStatusUtil, 127 | } 128 | }); 129 | } 130 | 131 | // ... 132 | } 133 | ``` 134 | 135 | 4. Optionally, this configuration can be dynamically set based on the `Platform` 136 | 137 | ```js 138 | import { Platform } from 'react-native'; 139 | 140 | const isMobile = Platform.OS === 'ios' || Platform.OS === 'android'; 141 | 142 | const customNetworkStatusUtil = () => { 143 | if (navigator && navigator.hasOwnProperty("onLine")) { 144 | return navigator.onLine; 145 | } 146 | 147 | return true; 148 | } 149 | 150 | connect.ChatSession.setGlobalConfig({ 151 | // ... 152 | webSocketManagerConfig: { 153 | ...(isMobile ? { isNetworkOnline: customNetworkStatusUtil } : {}), // use default behavior for browsers 154 | } 155 | }); 156 | ``` 157 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [16.x, 18.x, 19.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm ci 29 | - run: npm run release 30 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish NPM Package 2 | 3 | # Workflow to automat publishing to NPM, 4 | # - Uses the NPM_TOKEN GitHub secret 5 | # - Triggers when a new 'release' is created 6 | # - Executes a publish dry-run to list out files 7 | # - Publishes after manual approval from admin added to 'release' environment 8 | # - Builds/published with Node 16 LTS 9 | # 10 | # For more details, refer to https://github.com/amazon-connect/amazon-connect-chatjs/pull/165 11 | # Or refer to https://github.com/amazon-connect/amazon-connect-chatjs/blob/master/.github/docs/NpmPublishDocumentation.md 12 | 13 | on: 14 | workflow_dispatch: 15 | release: 16 | types: [created] 17 | 18 | env: 19 | RELEASE_NODE_VERSION: "16.x" # https://nodejs.dev/en/about/releases 20 | 21 | jobs: 22 | publish-dry-run: 23 | runs-on: ubuntu-latest 24 | environment: release 25 | 26 | steps: 27 | - uses: actions/checkout@v3 28 | - uses: actions/setup-node@v3 29 | with: 30 | node-version: ${{ env.RELEASE_NODE_VERSION }} 31 | registry-url: "https://registry.npmjs.org" 32 | 33 | - run: npm ci 34 | - run: npm run release 35 | 36 | - name: Set NPM_TOKEN 37 | run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc 38 | 39 | # Docs: https://docs.npmjs.com/cli/v8/commands/npm-whoami 40 | - run: npm whoami 41 | id: whoami 42 | 43 | - run: npm publish --dry-run 44 | 45 | - run: git status 46 | 47 | build-and-publish: 48 | needs: [publish-dry-run] 49 | runs-on: ubuntu-latest 50 | environment: 51 | name: release 52 | url: https://www.npmjs.com/package/amazon-connect-chatjs 53 | 54 | steps: 55 | - uses: actions/checkout@v3 56 | - uses: actions/setup-node@v3 57 | with: 58 | node-version: ${{ env.RELEASE_NODE_VERSION }} 59 | registry-url: "https://registry.npmjs.org" 60 | 61 | - run: npm install 62 | - run: npm run release 63 | 64 | - name: Create .npmrc 65 | run: | 66 | touch .npmrc 67 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc 68 | echo "registry=https://registry.npmjs.org/" >> .npmrc 69 | echo "always-auth=true" >> .npmrc 70 | 71 | - name: Publish NPM Package 72 | run: npm publish --access public --userconfig .npmrc 73 | env: 74 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | npm-debug.log* 3 | coverage 4 | .DS_Store 5 | dist -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [3.1.0] 8 | ### Added 9 | - Expose ChatJS version to global `window.connect.ChatJSVersion` 10 | - Add missing typescript declarations to `index.d.ts` 11 | - Updating `ChatGlobalConfig` interface in `index.d.ts` for React Native `webSocketManagerConfig` 12 | 13 | ## [3.0.6] 14 | ### Added 15 | - Updating connectivity and websocket logic 16 | - Fix mobile websocket termination issue on network disruption 17 | 18 | ## [3.0.5] 19 | ### Added 20 | - Updating `ChatGlobalConfig` interface in `index.d.ts` to include `features` and `customUserAgentSuffix` 21 | 22 | ## [3.0.4] 23 | ### Added 24 | - Adding read and delivered message receipt event types to `ChatEventContentType` 25 | - Implementing UserAgent configuration for AWSClient + Adding UA suffix property on GlobalConfig 26 | 27 | ## [3.0.3] 28 | ### Added 29 | - Authentication lifecycle events and APIs for authenticate customer flow block 30 | - Migrated baked-in dependency from AWS SDK v2 to AWS SDK v3: `src/client/aws-sdk-connectparticipant.js` #247 31 | 32 | ## [3.0.2] 33 | ### Added 34 | - fix message receipts getting disabled if custom throttleTime is passed 35 | 36 | ## [3.0.1] 37 | ### Added 38 | - add `disableCSM` to custom typings file index.d.ts. 39 | 40 | ## [3.0.0] 41 | ### Added 42 | - add custom typings file index.d.ts instead of auto-generating typings. 43 | 44 | ## [2.3.2] 45 | ### Fixed 46 | - Prevent overriding connect global namespace when initialized 47 | 48 | ## [2.3.1] 49 | ### Added 50 | - Update jest to fix security issues with old version. 51 | 52 | ## [2.2.5] 53 | ### Added 54 | - Added new callback onChatRehydrted callbacks whenever websocket fires `.rehydrated` event. 55 | 56 | ## [2.2.4] 57 | ### Fixed 58 | - onDeepHeartBeatFailure is undefined, updated websocketManager library file to fix the bug. 59 | 60 | ## [2.2.3] 61 | ### Fixed 62 | - .onConnectionEstablished() is fired twice after invoking .connect(); closes #124 63 | - enable message receipts by default; closes #132 64 | - expose deep heartbeat success/failure callback to clients 65 | 66 | 67 | ## [2.2.2] 68 | ### Fixed 69 | - reject send callbacks instead of returning null 70 | 71 | ## [2.2.1] 72 | 73 | ### Added 74 | - Updated README to include important note about ConnAck migration 75 | 76 | ### Fixed 77 | - Fix sendMessageReceipts to only send receipts if chat has not ended 78 | 79 | ## [2.2.0] 80 | ### Added 81 | - Updated amazon-connect-websocket-manager.js library to enable Deep Heartbeat change for Chat widget users. 82 | 83 | ## [2.1.0] 84 | ### Added 85 | - The [DescribeView API](https://docs.aws.amazon.com/connect-participant/latest/APIReference/API_DescribeView.html) 86 | 87 | ## [2.0.2] 88 | ### Added 89 | - Error message if API call is made before invoking session.connect(); #129 90 | 91 | ### Fixed 92 | - sendEvent contentType README documentation 93 | - delivered receipt logic causing deadlock promise; #131 94 | 95 | ## [2.0.1] 96 | ### Added 97 | - Browser Refresh and Persistent Chat documentation 98 | - More details to chatSession.sendAttachment README documentation 99 | - Improved ReactNativeSupport documentation 100 | 101 | ### Fixed 102 | - Exclude src folder when publishing code to npm 103 | - Remove hardcoded usage of console.* methods; addresses #127 104 | - Expose connectionDetails value; addresses #154 105 | 106 | ## [2.0.0] 107 | ### Added 108 | - Initial TypeScript migration: auto-generate `*.d.ts` files in dist folder. 109 | - Delete and gitignore `dist` folder 110 | - Add CDN link to README 111 | - Fix typo for setting messageReceipts?.throttleTime in updateThrottleTime 112 | 113 | ## [1.5.1] 114 | ### Added 115 | - updaing generating mapping file in dist folder. 116 | 117 | ## [1.5.0] 118 | ### Added 119 | - support React Native applications with latest WebSocketManager fix 120 | 121 | ## [1.4.0] 122 | ### Added 123 | - Migrate critical **connectionAcknowledge** event to CreateParticipantConnection API, and keep **sendEvent API** for non-critical events like typing/read/delivered. 124 | - Adding chatSession.onConnectionLost method which subscribes to the CHAT_EVENTS.CONNECTION_LOST event. 125 | 126 | ## [1.3.4] 127 | ### Added 128 | - Throttle typing event. Throttle wait time is set to 10 seconds. 129 | - add interactiveMessageResponse as a supported ContentType. 130 | 131 | ## [1.3.3] 132 | ### Changed 133 | - fix unsafe-eval usage in code by updating webpack config. 134 | - do not load CSM bundle if CSM is disabled. 135 | 136 | ## [1.3.2] 137 | ### Changed 138 | - add application/json as a supported ContentType. 139 | 140 | ## [1.3.1] 141 | ### Changed 142 | - fix csm initialization to add try-catch to prevent csm webworker initialization failures from affecting the main application. 143 | 144 | ## [1.3.0] 145 | ### Added 146 | - Add message receipt. Message Receipts allow the sender of a chat message to view when their message has been delivered and read (seen) by the recipient. 147 | - Add browser and OS usage client side metric to enhance the proactive identify issues. 148 | ### Changed 149 | - fix global declaration. 150 | 151 | ## [1.2.0] 152 | ### Added 153 | - Add client side metric service in order to enhance the customer experience and proactively identify issues. Detail: [README.md](https://github.com/amazon-connect/amazon-connect-chatjs#Client-side-metric). 154 | - Update `.babelrc` file to fix the error of `ReferenceError: regeneratorRuntime is not defined`. 155 | 156 | ## [1.1.14] 157 | ### Added 158 | - fix WebSocketManager logger so its instance is tied to WebsocketManager instance. Fixes the case where multiple connections are initiated in 1 browser session. 159 | - update log message to contain logLevel and logMetaData 160 | 161 | ## [1.1.13] 162 | ### Added 163 | - enabled logs for WebSocketManager 164 | - add advanced_log level to Logger for customers to identify critical logs needed for WebSocket production debugging. 165 | - add ability to re-connect to web socket after connection has ended. 166 | 167 | ### Changed 168 | - remove websocket ended check for GetTranscript to allow GetTranscript after web socket has ended. 169 | - updated package-lock [lockfileVersion](https://docs.npmjs.com/cli/v8/configuring-npm/package-lock-json#lockfileversion) to 2 170 | 171 | ## [1.1.12] 172 | ### Added 173 | - This CHANGELOG file to serve as an evolving example of a standardized open source project CHANGELOG. 174 | - Support for concurrent customer sessions and an agent session. Concurrent agent sessions are unsupported in [Streams](https://github.com/amazon-connect/amazon-connect-streams) and remain unsupported in ChatJS. 175 | - Ability to configure a customized logger and other logging updates. 176 | 177 | ### Changed 178 | - Upgrade babel, jest, and webpack-dev-server dependencies. 179 | - Minor code refactorings. 180 | 181 | ### Fixed 182 | - Fix ChatJS connections breaking when [Streams](https://github.com/amazon-connect/amazon-connect-streams) is terminated and re-initialized. 183 | - Improve code coverage. 184 | 185 | ## [1.1.11] - 2022-05-20 186 | ### Changed 187 | - Upgrade async, minimist, node-forge dependencies. 188 | 189 | ## [1.1.10] - 2022-03-10 190 | ### Changed 191 | - Bumped webpack, babel, eslint, and jest 192 | 193 | [Unreleased]: https://github.com/amazon-connect/amazon-connect-chatjs/compare/4378177e5d66b0615fe8435d9ed352199b8b7a9d...HEAD 194 | [1.1.11]: https://github.com/amazon-connect/amazon-connect-chatjs/compare/b1e631b105bd6c6f8535cfe172678b517f5e0353...4378177e5d66b0615fe8435d9ed352199b8b7a9d 195 | [1.1.10]: https://github.com/amazon-connect/amazon-connect-chatjs/compare/9ba35f8e63a8e6a86fa3b3128a0d91ca7e841e55...b1e631b105bd6c6f8535cfe172678b517f5e0353 196 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amazon-connect-chatjs", 3 | "version": "3.1.0", 4 | "main": "dist/amazon-connect-chat.js", 5 | "types": "dist/index.d.ts", 6 | "engines": { 7 | "node": ">=14.0.0" 8 | }, 9 | "directories": { 10 | "lib": "./dist" 11 | }, 12 | "files": [ 13 | "dist/", 14 | "CHANGELOG.md" 15 | ], 16 | "description": "Provides chat support to AmazonConnect customers", 17 | "scripts": { 18 | "test": "jest", 19 | "test:watch": "jest --watchAll", 20 | "release": "tsc && npm run lint && jest && webpack --mode=production", 21 | "devo": "jest && webpack --mode=development", 22 | "watch": "webpack --watch", 23 | "dev": "webpack --mode=development && webpack --watch", 24 | "server": "webpack-dev-server --hot", 25 | "clean": "rm -rf build/ node_modules build", 26 | "release-watch": "npm run release && npm run watch", 27 | "posttest": "generate-coverage-data", 28 | "lint": "eslint --ext .js ./src", 29 | "lint:fix": "eslint --fix 'src/**/*.js'" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/amazon-connect/amazon-connect-chatjs.git" 34 | }, 35 | "keywords": [ 36 | "amazon", 37 | "connect", 38 | "streamJs", 39 | "chatjs" 40 | ], 41 | "jest": { 42 | "testEnvironment": "jsdom", 43 | "setupFiles": [ 44 | "./test/jestSetup.js" 45 | ], 46 | "transform": { 47 | ".(js|jsx)$": "babel-jest" 48 | }, 49 | "testRegex": "/src/.*\\.spec\\.js$", 50 | "moduleFileExtensions": [ 51 | "ts", 52 | "tsx", 53 | "js", 54 | "json" 55 | ], 56 | "collectCoverage": true, 57 | "collectCoverageFrom": [ 58 | "src/**/*.js" 59 | ], 60 | "coverageReporters": [ 61 | "json", 62 | "html", 63 | "cobertura", 64 | "lcov", 65 | "text" 66 | ] 67 | }, 68 | "author": "Amazon Web Services", 69 | "license": "Apache-2.0", 70 | "devDependencies": { 71 | "@babel/cli": "^7.17.6", 72 | "@babel/core": "^7.17.0", 73 | "@babel/eslint-parser": "^7.18.9", 74 | "@babel/preset-env": "^7.15.4", 75 | "babel-loader": "^8.2.4", 76 | "eslint": "^8.9.0", 77 | "eslint-config-prettier": "^6.11.0", 78 | "eslint-plugin-jest": "^26.6.0", 79 | "eslint-plugin-prettier": "^4.0.0", 80 | "jest": "^29.0.0", 81 | "regenerator-runtime": "^0.13.9", 82 | "typescript": "^4.4.2", 83 | "webpack": "^5.54.0", 84 | "webpack-cli": "^4.8.0", 85 | "webpack-dev-server": "^4.8.1", 86 | "copy-webpack-plugin": "^12.0.2" 87 | }, 88 | "dependencies": { 89 | "detect-browser": "5.3.0", 90 | "jest-environment-jsdom": "^29.7.0", 91 | "lodash.throttle": "^4.1.1", 92 | "sprintf-js": "^1.1.2" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/chat.js: -------------------------------------------------------------------------------- 1 | import { CHAT_CONFIGURATIONS } from './constants'; 2 | 3 | //Placeholder 4 | export default class Chat { 5 | static getActiveChats() { 6 | return CHAT_CONFIGURATIONS.CONCURRENT_CHATS; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/chat.spec.js: -------------------------------------------------------------------------------- 1 | import Chat from "./chat"; 2 | 3 | //Placeholder 4 | describe("Chat JS", () => { 5 | beforeEach(() => { 6 | //Something 7 | }); 8 | test("getActiveChats should be called", () => { 9 | expect(Chat.getActiveChats()).toBe(10); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/client/client.js: -------------------------------------------------------------------------------- 1 | 2 | //Note: this imports AWS instead from aws-sdk npm package - details in ReadMe 3 | import { 4 | ConnectParticipantClient, 5 | CreateParticipantConnectionCommand, 6 | DisconnectParticipantCommand, 7 | SendMessageCommand, 8 | StartAttachmentUploadCommand, 9 | CompleteAttachmentUploadCommand, 10 | GetAttachmentCommand, 11 | SendEventCommand, 12 | GetTranscriptCommand, 13 | CancelParticipantAuthenticationCommand, 14 | DescribeViewCommand, 15 | GetAuthenticationUrlCommand, 16 | } from "./aws-sdk-connectparticipant"; 17 | import { UnImplementedMethodException } from "../core/exceptions"; 18 | import { GlobalConfig } from "../globalConfig"; 19 | import { 20 | REGIONS 21 | } from "../constants"; 22 | import { LogManager } from "../log"; 23 | import throttle from "lodash.throttle"; 24 | import { CONTENT_TYPE, TYPING_VALIDITY_TIME } from '../constants'; 25 | import packageJson from '../../package.json'; 26 | 27 | const DEFAULT_PREFIX = "Amazon-Connect-ChatJS-ChatClient"; 28 | 29 | class ChatClientFactoryImpl { 30 | constructor() { 31 | this.clientCache = {}; 32 | 33 | } 34 | 35 | getCachedClient(optionsInput, logMetaData) { 36 | let region = GlobalConfig.getRegionOverride() || optionsInput.region || GlobalConfig.getRegion() || REGIONS.pdx; 37 | logMetaData.region = region; 38 | if (this.clientCache[region]) { 39 | return this.clientCache[region]; 40 | } 41 | let client = this._createAwsClient(region, logMetaData); 42 | this.clientCache[region] = client; 43 | return client; 44 | } 45 | 46 | _createAwsClient(region, logMetaData) { 47 | let endpointOverride = GlobalConfig.getEndpointOverride(); 48 | let endpointUrl = `https://participant.connect.${region}.amazonaws.com`; 49 | if (endpointOverride) { 50 | endpointUrl = endpointOverride; 51 | } 52 | return new AWSChatClient({ 53 | endpoint: endpointUrl, 54 | region: region, 55 | logMetaData, 56 | customUserAgentSuffix: GlobalConfig.getCustomUserAgentSuffix() 57 | }); 58 | } 59 | } 60 | 61 | /*eslint-disable*/ 62 | class ChatClient { 63 | sendMessage(participantToken, message, type) { 64 | throw new UnImplementedMethodException("sendTextMessage in ChatClient"); 65 | } 66 | 67 | sendAttachment(participantToken, attachment, metadata) { 68 | throw new UnImplementedMethodException("sendAttachment in ChatClient"); 69 | } 70 | 71 | downloadAttachment(participantToken, attachmentId) { 72 | throw new UnImplementedMethodException("downloadAttachment in ChatClient"); 73 | } 74 | 75 | disconnectParticipant(participantToken) { 76 | throw new UnImplementedMethodException("disconnectParticipant in ChatClient"); 77 | } 78 | 79 | sendEvent(connectionToken, contentType, content) { 80 | throw new UnImplementedMethodException("sendEvent in ChatClient"); 81 | } 82 | 83 | createParticipantConnection(participantToken, type) { 84 | throw new UnImplementedMethodException("createParticipantConnection in ChatClient"); 85 | } 86 | 87 | describeView() { 88 | throw new UnImplementedMethodException("describeView in ChatClient"); 89 | } 90 | 91 | getAuthenticationUrl() { 92 | throw new UnImplementedMethodException("getAuthenticationUrl in ChatClient"); 93 | } 94 | 95 | cancelParticipantAuthentication() { 96 | throw new UnImplementedMethodException("cancelParticipantAuthentication in ChatClient"); 97 | } 98 | } 99 | /*eslint-enable*/ 100 | 101 | class AWSChatClient extends ChatClient { 102 | constructor(args) { 103 | super(); 104 | const customUserAgent = args.customUserAgentSuffix ? `AmazonConnect-ChatJS/${packageJson.version} ${args.customUserAgentSuffix}` : `AmazonConnect-ChatJS/${packageJson.version}`; 105 | this.chatClient = new ConnectParticipantClient({ 106 | credentials: { 107 | accessKeyId: '', 108 | secretAccessKey: '' 109 | }, 110 | endpoint: args.endpoint, 111 | region: args.region, 112 | customUserAgent 113 | }); 114 | this.invokeUrl = args.endpoint; 115 | this.logger = LogManager.getLogger({ prefix: DEFAULT_PREFIX, logMetaData: args.logMetaData }); 116 | } 117 | 118 | describeView(viewToken, connectionToken) { 119 | let self = this; 120 | let params = { 121 | ViewToken: viewToken, 122 | ConnectionToken: connectionToken 123 | }; 124 | const command = new DescribeViewCommand(params); 125 | return self._sendRequest(command) 126 | .then((res) => { 127 | self.logger.info("Successful describe view request")?.sendInternalLogToServer?.(); 128 | return res; 129 | }) 130 | .catch((err) => { 131 | self.logger.error("describeView gave an error response", err)?.sendInternalLogToServer?.(); 132 | return Promise.reject(err); 133 | }); 134 | } 135 | 136 | cancelParticipantAuthentication(connectionToken, sessionId) { 137 | let self = this; 138 | let params = { 139 | ConnectionToken: connectionToken, 140 | SessionId: sessionId, 141 | } 142 | const command = new CancelParticipantAuthenticationCommand(params); 143 | return self._sendRequest(command) 144 | .then((res) => { 145 | self.logger.info("Successful getAuthenticationUrl request")?.sendInternalLogToServer?.(); 146 | return res; 147 | }) 148 | .catch((err) => { 149 | self.logger.error("getAuthenticationUrl gave an error response", err)?.sendInternalLogToServer?.(); 150 | return Promise.reject(err); 151 | }); 152 | } 153 | 154 | getAuthenticationUrl(connectionToken, redirectUri, sessionId) { 155 | let self = this; 156 | let params = { 157 | RedirectUri: redirectUri, 158 | SessionId: sessionId, 159 | ConnectionToken: connectionToken 160 | }; 161 | const command = new GetAuthenticationUrlCommand(params); 162 | return self._sendRequest(command) 163 | .then((res) => { 164 | self.logger.info("Successful getAuthenticationUrl request")?.sendInternalLogToServer?.(); 165 | return res; 166 | }) 167 | .catch((err) => { 168 | self.logger.error("getAuthenticationUrl gave an error response", err)?.sendInternalLogToServer?.(); 169 | return Promise.reject(err); 170 | }); 171 | } 172 | 173 | createParticipantConnection(participantToken, type, acknowledgeConnection) { 174 | let self = this; 175 | var params = { 176 | ParticipantToken: participantToken, 177 | Type: type, 178 | ConnectParticipant: acknowledgeConnection 179 | }; 180 | 181 | const command = new CreateParticipantConnectionCommand(params); 182 | return self._sendRequest(command) 183 | .then((res) => { 184 | self.logger.info("Successfully create connection request")?.sendInternalLogToServer?.(); 185 | return res; 186 | }) 187 | .catch((err) => { 188 | self.logger.error("Error when creating connection request ", err)?.sendInternalLogToServer?.(); 189 | return Promise.reject(err); 190 | }); 191 | } 192 | 193 | disconnectParticipant(connectionToken) { 194 | let self = this; 195 | let params = { 196 | ConnectionToken: connectionToken 197 | }; 198 | 199 | const command = new DisconnectParticipantCommand(params); 200 | return self._sendRequest(command) 201 | .then((res) => { 202 | self.logger.info("Successfully disconnect participant")?.sendInternalLogToServer?.(); 203 | return res; 204 | }) 205 | .catch((err) => { 206 | self.logger.error("Error when disconnecting participant ", err)?.sendInternalLogToServer?.(); 207 | return Promise.reject(err); 208 | }); 209 | } 210 | 211 | getTranscript(connectionToken, args) { 212 | let self = this; 213 | var params = { 214 | MaxResults: args.maxResults, 215 | NextToken: args.nextToken, 216 | ScanDirection: args.scanDirection, 217 | SortOrder: args.sortOrder, 218 | StartPosition: { 219 | Id: args.startPosition.id, 220 | AbsoluteTime: args.startPosition.absoluteTime, 221 | MostRecent: args.startPosition.mostRecent 222 | }, 223 | ConnectionToken: connectionToken 224 | }; 225 | if (args.contactId) { 226 | params.ContactId = args.contactId; 227 | } 228 | const command = new GetTranscriptCommand(params); 229 | return self._sendRequest(command) 230 | .then((res) => { 231 | this.logger.info("Successfully get transcript"); 232 | return res; 233 | }) 234 | .catch((err) => { 235 | this.logger.error("Get transcript error", err); 236 | return Promise.reject(err); 237 | }); 238 | } 239 | 240 | sendMessage(connectionToken, content, contentType) { 241 | let self = this; 242 | let params = { 243 | Content: content, 244 | ContentType: contentType, 245 | ConnectionToken: connectionToken 246 | }; 247 | const command = new SendMessageCommand(params); 248 | return self._sendRequest(command) 249 | .then((res) => { 250 | const logContent = { id: res.data?.Id, contentType: params.ContentType }; 251 | this.logger.debug("Successfully send message", logContent); 252 | return res; 253 | }) 254 | .catch((err) => { 255 | this.logger.error("Send message error", err, { contentType: params.ContentType }); 256 | return Promise.reject(err); 257 | }); 258 | } 259 | 260 | sendAttachment(connectionToken, attachment, metadata) { 261 | let self = this; 262 | const startUploadRequestParams = { 263 | ContentType: attachment.type, 264 | AttachmentName: attachment.name, 265 | AttachmentSizeInBytes: attachment.size, 266 | ConnectionToken: connectionToken 267 | }; 268 | const startUploadCommand = new StartAttachmentUploadCommand(startUploadRequestParams); 269 | const logContent = { contentType: attachment.type, size: attachment.size }; 270 | return self._sendRequest(startUploadCommand) 271 | .then(startUploadResponse => { 272 | return self._uploadToS3(attachment, startUploadResponse.data.UploadMetadata) 273 | .then(() => { 274 | const completeUploadRequestParams = { 275 | AttachmentIds: [startUploadResponse.data.AttachmentId], 276 | ConnectionToken: connectionToken 277 | }; 278 | this.logger.debug("Successfully upload attachment", { ...logContent, attachmentId: startUploadResponse.data?.AttachmentId }); 279 | const completeUploadCommand = new CompleteAttachmentUploadCommand(completeUploadRequestParams); 280 | return self._sendRequest(completeUploadCommand); 281 | }); 282 | }) 283 | .catch((err) => { 284 | this.logger.error("Upload attachment error", err, logContent); 285 | return Promise.reject(err); 286 | }); 287 | } 288 | 289 | _uploadToS3(file, metadata) { 290 | return fetch(metadata.Url, { 291 | method: "PUT", 292 | headers: metadata.HeadersToInclude, 293 | body: file 294 | }); 295 | } 296 | 297 | downloadAttachment(connectionToken, attachmentId) { 298 | let self = this; 299 | const params = { 300 | AttachmentId: attachmentId, 301 | ConnectionToken: connectionToken 302 | }; 303 | const logContent = { attachmentId }; 304 | const command = new GetAttachmentCommand(params); 305 | return self._sendRequest(command) 306 | .then(response => { 307 | this.logger.debug("Successfully download attachment", logContent); 308 | return self._downloadUrl(response.data.Url); 309 | }) 310 | .catch(err => { 311 | this.logger.error("Download attachment error", err, logContent); 312 | return Promise.reject(err); 313 | }); 314 | } 315 | 316 | _downloadUrl(url) { 317 | return fetch(url) 318 | .then(t => t.blob()) 319 | .catch(err => { return Promise.reject(err); }); 320 | } 321 | 322 | 323 | sendEvent(connectionToken, contentType, content) { 324 | let self = this; 325 | if (contentType === CONTENT_TYPE.typing) { 326 | return self.throttleEvent(connectionToken, contentType, content) 327 | } 328 | return self._submitEvent(connectionToken, contentType, content); 329 | } 330 | 331 | throttleEvent = throttle((connectionToken, contentType, content) => { 332 | return this._submitEvent(connectionToken, contentType, content); 333 | }, TYPING_VALIDITY_TIME, { trailing: false, leading: true }) 334 | 335 | _submitEvent(connectionToken, contentType, content) { 336 | let self = this; 337 | var params = { 338 | ConnectionToken: connectionToken, 339 | ContentType: contentType, 340 | Content: content 341 | }; 342 | const command = new SendEventCommand(params); 343 | const logContent = { contentType }; 344 | return self._sendRequest(command) 345 | .then((res) => { 346 | this.logger.debug("Successfully send event", { ...logContent, id: res.data?.Id }); 347 | return res; 348 | }) 349 | .catch((err) => { 350 | return Promise.reject(err); 351 | }); 352 | } 353 | 354 | _sendRequest(command) { 355 | return this.chatClient.send(command) 356 | .then(response => { 357 | return { data: response }; 358 | }) 359 | .catch(error => { 360 | const errObj = { 361 | type: error.name, 362 | message: error.message, 363 | stack: error.stack ? error.stack.split('\n') : [], 364 | statusCode: error.$metadata ? error.$metadata.httpStatusCode : undefined, 365 | }; 366 | return Promise.reject(errObj); 367 | }); 368 | } 369 | } 370 | 371 | let ChatClientFactory = new ChatClientFactoryImpl(); 372 | export { ChatClientFactory }; 373 | -------------------------------------------------------------------------------- /src/client/client.spec.js: -------------------------------------------------------------------------------- 1 | import { ChatClientFactory } from "./client"; 2 | import { CONTENT_TYPE } from "../constants"; 3 | import { GlobalConfig } from "../globalConfig"; 4 | import packageJson from '../../package.json'; 5 | 6 | jest.mock('../globalConfig', () => { 7 | return { 8 | GlobalConfig: { 9 | getRegionOverride: jest.fn(), 10 | getRegion: jest.fn(), 11 | getEndpointOverride: jest.fn(), 12 | getCustomUserAgentSuffix: jest.fn(), 13 | } 14 | } 15 | }); 16 | 17 | describe("client test cases", () => { 18 | const connectionToken = "connectionToken"; 19 | const content = "content"; 20 | const options = {}; 21 | const logMetaData = {}; 22 | var chatClient = ChatClientFactory.getCachedClient(options, logMetaData); 23 | 24 | beforeEach(() => { 25 | jest.spyOn(chatClient, "_submitEvent").mockImplementation(() => {}); 26 | jest.spyOn(chatClient, "_sendRequest").mockResolvedValue({}); 27 | jest.useFakeTimers(); 28 | jest.clearAllTimers(); 29 | jest.clearAllMocks(); 30 | }); 31 | 32 | describe("event throttling test cases", () => { 33 | 34 | test("typing event should be throttled if content type is typing", () => { 35 | let count = 0; 36 | let typingInterval = setInterval(() => { 37 | if (count > 8) { 38 | clearInterval(typingInterval); 39 | } 40 | count++; 41 | chatClient.sendEvent(connectionToken, CONTENT_TYPE.typing, content); 42 | }, 100); 43 | jest.advanceTimersByTime(900); 44 | expect(chatClient._submitEvent).toHaveBeenCalledTimes(1); 45 | }); 46 | 47 | test("Other events should not be throttled", () => { 48 | for (let key in CONTENT_TYPE) { 49 | jest.clearAllTimers(); 50 | jest.clearAllMocks(); 51 | if (key === "typing") { 52 | continue; 53 | } 54 | let count = 0; 55 | let typingInterval = setInterval(() => { 56 | if (count > 8) { 57 | clearInterval(typingInterval); 58 | } 59 | count++; 60 | chatClient.sendEvent(connectionToken, CONTENT_TYPE[key], content); 61 | }, 100); 62 | jest.advanceTimersByTime(900); 63 | expect(chatClient._submitEvent).toHaveBeenCalledTimes(9); 64 | } 65 | }); 66 | }); 67 | 68 | describe("Client Method Tests", () => { 69 | beforeEach(() => { 70 | jest.spyOn(chatClient, "_submitEvent").mockImplementation(() => {}); 71 | jest.spyOn(chatClient, "_sendRequest").mockResolvedValue({}); 72 | jest.useFakeTimers(); 73 | jest.clearAllTimers(); 74 | jest.clearAllMocks(); 75 | }); 76 | const options = {}; 77 | const logMetaData = {}; 78 | var chatClient = ChatClientFactory.getCachedClient(options, logMetaData); 79 | 80 | describe("DescribeView", () => { 81 | test("No errors thrown in happy case", async () => { 82 | expect(chatClient.describeView("token", "type")).resolves.toEqual({}); 83 | }); 84 | test("Promise rejects in error case", async () => { 85 | jest.spyOn(chatClient, "_sendRequest").mockRejectedValueOnce(new Error()); 86 | expect(chatClient.describeView("token", "type")).rejects.toThrow(); 87 | }); 88 | }); 89 | 90 | describe("GetAuthenticationUrl", () => { 91 | test("No errors thrown in happy case", async () => { 92 | expect(chatClient.getAuthenticationUrl("connectionToken", "redirectUri", "sessionId")).resolves.toEqual({}); 93 | }); 94 | test("Promise rejects in error case", async () => { 95 | jest.spyOn(chatClient, "_sendRequest").mockRejectedValueOnce(new Error()); 96 | expect(chatClient.getAuthenticationUrl("connectionToken", "redirectUri", "sessionId")).rejects.toThrow(); 97 | }); 98 | }); 99 | 100 | describe("cancelParticipantAuthentication", () => { 101 | test("No errors thrown in happy case", async () => { 102 | expect(chatClient.cancelParticipantAuthentication("connectionToken", "sessionId")).resolves.toEqual({}); 103 | }); 104 | test("Promise rejects in error case", async () => { 105 | jest.spyOn(chatClient, "_sendRequest").mockRejectedValueOnce(new Error()); 106 | expect(chatClient.cancelParticipantAuthentication("connectionToken", "sessionId")).rejects.toThrow(); 107 | }); 108 | }); 109 | 110 | describe("CreateParticipantConnection", () => { 111 | test("No errors thrown in happy case", async () => { 112 | expect(chatClient.createParticipantConnection("token", "type", "acknowledgeConnection")).resolves.toEqual({}); 113 | }); 114 | test("Promise rejects in error case", async () => { 115 | jest.spyOn(chatClient, "_sendRequest").mockRejectedValueOnce(new Error()); 116 | expect(chatClient.createParticipantConnection("token", "type", "acknowledgeConnection")).rejects.toThrow(); 117 | }); 118 | }); 119 | 120 | describe("DisconnectParticipant", () => { 121 | test("No errors thrown in happy case", async () => { 122 | expect(chatClient.disconnectParticipant("token")).resolves.toEqual({}); 123 | }); 124 | test("Promise rejects in error case", async () => { 125 | jest.spyOn(chatClient, "_sendRequest").mockRejectedValueOnce(new Error()); 126 | expect(chatClient.disconnectParticipant("token")).rejects.toThrow(); 127 | }); 128 | }); 129 | 130 | describe("GetTranscript", () => { 131 | const args = { 132 | maxResults: "maxResults", 133 | nextToken: "nextToken", 134 | scanDirection: "scanDirection", 135 | sortOrder: "sortOrder", 136 | startPosition: { 137 | id: "id", 138 | absoluteTime: "absoluteTime", 139 | mostRecent: "mostRecent", 140 | }, 141 | } 142 | test("No errors thrown in happy case", async () => { 143 | expect(chatClient.getTranscript("token", args)).resolves.toEqual({}); 144 | }); 145 | test("Promise rejects in error case", async () => { 146 | jest.spyOn(chatClient, "_sendRequest").mockRejectedValueOnce(new Error()); 147 | expect(chatClient.getTranscript("token", args)).rejects.toThrow(); 148 | }); 149 | }); 150 | 151 | describe("SendMessage", () => { 152 | test("No errors thrown in happy case", async () => { 153 | expect(chatClient.sendMessage("token", "content", "contentType")).resolves.toEqual({}); 154 | }); 155 | test("Promise rejects in error case", async () => { 156 | jest.spyOn(chatClient, "_sendRequest").mockRejectedValueOnce(new Error()); 157 | expect(chatClient.sendMessage("token", "content", "contentType")).rejects.toThrow(); 158 | }); 159 | }); 160 | 161 | describe("UserAgent Configs", () => { 162 | test("Default suffix", async () => { 163 | expect(chatClient.chatClient.config.customUserAgent.length).toEqual(1); 164 | expect(chatClient.chatClient.config.customUserAgent[0][0]).toEqual(`AmazonConnect-ChatJS/${packageJson.version}`); 165 | }); 166 | test("Passed insuffix", async () => { 167 | GlobalConfig.getCustomUserAgentSuffix.mockReturnValue('Test/1.0.0'); 168 | const testClient = ChatClientFactory._createAwsClient(options, logMetaData); 169 | expect(testClient.chatClient.config.customUserAgent.length).toEqual(1); 170 | expect(testClient.chatClient.config.customUserAgent[0][0]).toEqual(`AmazonConnect-ChatJS/${packageJson.version} Test/1.0.0`); 171 | }); 172 | }); 173 | }); 174 | }); -------------------------------------------------------------------------------- /src/config/csmConfig.js: -------------------------------------------------------------------------------- 1 | export const CHAT_WIDGET_METRIC_NAME_SPACE = "chat-widget"; 2 | export const DEFAULT_WIDGET_TYPE = "CustomChatWidget"; 3 | 4 | export const getLdasEndpointUrl = (region) => { 5 | return `https://ieluqbvv.telemetry.connect.${region}.amazonaws.com/prod`; 6 | }; -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | //Placeholder 2 | export const CHAT_CONFIGURATIONS = { 3 | CONCURRENT_CHATS: 10 4 | }; 5 | export const PARTICIPANT_TOKEN_HEADER = "x-amzn-connect-participant-token"; 6 | export const AUTH_HEADER = "X-Amz-Bearer"; 7 | 8 | export const DEFAULT_MESSAGE_RECEIPTS_THROTTLE_MS = 5000; 9 | 10 | export const FEATURES = { 11 | MESSAGE_RECEIPTS_ENABLED: "MESSAGE_RECEIPTS_ENABLED" 12 | }; 13 | 14 | export const RESOURCE_PATH = { 15 | CONNECTION_DETAILS: "/contact/chat/participant/connection-details", 16 | MESSAGE: "/participant/message", 17 | TRANSCRIPT: "/participant/transcript", 18 | EVENT: "/participant/event", 19 | DISCONNECT: "/participant/disconnect", 20 | PARTICIPANT_CONNECTION: "/participant/connection", 21 | ATTACHMENT: "/participant/attachment" 22 | }; 23 | 24 | export const SESSION_TYPES = { 25 | AGENT: "AGENT", 26 | CUSTOMER: "CUSTOMER" 27 | }; 28 | 29 | export const CSM_CATEGORY = { 30 | API: "API", 31 | UI: "UI" 32 | }; 33 | 34 | export const ACPS_METHODS = { 35 | SEND_MESSAGE: "SendMessage", 36 | SEND_ATTACHMENT: "SendAttachment", 37 | DOWNLOAD_ATTACHMENT: "DownloadAttachment", 38 | SEND_EVENT: "SendEvent", 39 | GET_TRANSCRIPT: "GetTranscript", 40 | DISCONNECT_PARTICIPANT: "DisconnectParticipant", 41 | CREATE_PARTICIPANT_CONNECTION: "CreateParticipantConnection", 42 | DESCRIBE_VIEW: "DescribeView", 43 | }; 44 | 45 | export const WEBSOCKET_EVENTS = { 46 | ConnectionLost: "WebsocketConnectionLost", 47 | ConnectionGained: "WebsocketConnectionGained", 48 | Ended: "WebsocketEnded", 49 | IncomingMessage: "WebsocketIncomingMessage", 50 | InitWebsocket: "InitWebsocket", 51 | DeepHeartbeatSuccess: "WebsocketDeepHeartbeatSuccess", 52 | DeepHeartbeatFailure: "WebsocketDeepHeartbeatFailure" 53 | }; 54 | 55 | export const CHAT_EVENTS = { 56 | INCOMING_MESSAGE: "INCOMING_MESSAGE", 57 | INCOMING_TYPING: "INCOMING_TYPING", 58 | INCOMING_READ_RECEIPT: "INCOMING_READ_RECEIPT", 59 | INCOMING_DELIVERED_RECEIPT: "INCOMING_DELIVERED_RECEIPT", 60 | CONNECTION_ESTABLISHED: "CONNECTION_ESTABLISHED", 61 | CONNECTION_LOST: "CONNECTION_LOST", 62 | CONNECTION_BROKEN: "CONNECTION_BROKEN", 63 | CONNECTION_ACK: "CONNECTION_ACK", 64 | CHAT_ENDED: "CHAT_ENDED", 65 | MESSAGE_METADATA: "MESSAGEMETADATA", 66 | PARTICIPANT_IDLE: "PARTICIPANT_IDLE", 67 | PARTICIPANT_RETURNED: "PARTICIPANT_RETURNED", 68 | PARTICIPANT_INVITED: "PARTICIPANT_INVITED", 69 | AUTODISCONNECTION: "AUTODISCONNECTION", 70 | DEEP_HEARTBEAT_SUCCESS: "DEEP_HEARTBEAT_SUCCESS", 71 | DEEP_HEARTBEAT_FAILURE: "DEEP_HEARTBEAT_FAILURE", 72 | AUTHENTICATION_INITIATED: "AUTHENTICATION_INITIATED", 73 | AUTHENTICATION_SUCCESSFUL: "AUTHENTICATION_SUCCESSFUL", 74 | AUTHENTICATION_FAILED: "AUTHENTICATION_FAILED", 75 | AUTHENTICATION_TIMEOUT: "AUTHENTICATION_TIMEOUT", 76 | AUTHENTICATION_EXPIRED: "AUTHENTICATION_EXPIRED", 77 | AUTHENTICATION_CANCELED: "AUTHENTICATION_CANCELED", 78 | PARTICIPANT_DISPLAY_NAME_UPDATED: "PARTICIPANT_DISPLAY_NAME_UPDATED", 79 | CHAT_REHYDRATED: "CHAT_REHYDRATED" 80 | }; 81 | 82 | export const CONTENT_TYPE = { 83 | textPlain: "text/plain", 84 | textMarkdown: "text/markdown", 85 | textCsv: "text/csv", 86 | applicationDoc: "application/msword", 87 | applicationDocx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 88 | applicationJson: "application/json", 89 | applicationPdf: "application/pdf", 90 | applicationPpt: "application/vnd.ms-powerpoint", 91 | applicationPptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", 92 | applicationXls: "application/vnd.ms-excel", 93 | applicationXlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 94 | authenticationInitiated: "application/vnd.amazonaws.connect.event.authentication.initiated", 95 | authenticationSuccessful: "application/vnd.amazonaws.connect.event.authentication.succeeded", 96 | authenticationFailed: "application/vnd.amazonaws.connect.event.authentication.failed", 97 | authenticationTimeout: "application/vnd.amazonaws.connect.event.authentication.timeout", 98 | authenticationExpired: "application/vnd.amazonaws.connect.event.authentication.expired", 99 | authenticationCanceled: "application/vnd.amazonaws.connect.event.authentication.cancelled", 100 | participantDisplayNameUpdated: "application/vnd.amazonaws.connect.event.participant.displayname.updated", 101 | imageJpg: "image/jpeg", 102 | imagePng: "image/png", 103 | audioWav: "audio/wav", 104 | audioXWav: "audio/x-wav", //Firefox 105 | audioVndWave: "audio/vnd.wave", //IE 106 | connectionAcknowledged: "application/vnd.amazonaws.connect.event.connection.acknowledged", 107 | typing: "application/vnd.amazonaws.connect.event.typing", 108 | participantJoined: "application/vnd.amazonaws.connect.event.participant.joined", 109 | participantLeft: "application/vnd.amazonaws.connect.event.participant.left", 110 | participantActive: "application/vnd.amazonaws.connect.event.participant.active", 111 | participantInactive: "application/vnd.amazonaws.connect.event.participant.inactive", 112 | transferSucceeded: "application/vnd.amazonaws.connect.event.transfer.succeeded", 113 | transferFailed: "application/vnd.amazonaws.connect.event.transfer.failed", 114 | chatEnded: "application/vnd.amazonaws.connect.event.chat.ended", 115 | interactiveMessage: "application/vnd.amazonaws.connect.message.interactive", 116 | interactiveMessageResponse: "application/vnd.amazonaws.connect.message.interactive.response", 117 | readReceipt: "application/vnd.amazonaws.connect.event.message.read", 118 | deliveredReceipt: "application/vnd.amazonaws.connect.event.message.delivered", 119 | participantIdle: "application/vnd.amazonaws.connect.event.participant.idle", 120 | participantReturned: "application/vnd.amazonaws.connect.event.participant.returned", 121 | participantInvited: "application/vnd.amazonaws.connect.event.participant.invited", 122 | autoDisconnection: "application/vnd.amazonaws.connect.event.participant.autodisconnection", 123 | chatRehydrated: "application/vnd.amazonaws.connect.event.chat.rehydrated" 124 | }; 125 | 126 | export const CHAT_EVENT_TYPE_MAPPING = { 127 | [CONTENT_TYPE.typing]: CHAT_EVENTS.INCOMING_TYPING, 128 | [CONTENT_TYPE.readReceipt]: CHAT_EVENTS.INCOMING_READ_RECEIPT, 129 | [CONTENT_TYPE.deliveredReceipt]: CHAT_EVENTS.INCOMING_DELIVERED_RECEIPT, 130 | [CONTENT_TYPE.participantIdle]: CHAT_EVENTS.PARTICIPANT_IDLE, 131 | [CONTENT_TYPE.authenticationInitiated]: CHAT_EVENTS.AUTHENTICATION_INITIATED, 132 | [CONTENT_TYPE.authenticationSuccessful]: CHAT_EVENTS.AUTHENTICATION_SUCCESSFUL, 133 | [CONTENT_TYPE.authenticationFailed]: CHAT_EVENTS.AUTHENTICATION_FAILED, 134 | [CONTENT_TYPE.authenticationTimeout]: CHAT_EVENTS.AUTHENTICATION_TIMEOUT, 135 | [CONTENT_TYPE.authenticationExpired]: CHAT_EVENTS.AUTHENTICATION_EXPIRED, 136 | [CONTENT_TYPE.authenticationCanceled]: CHAT_EVENTS.AUTHENTICATION_CANCELED, 137 | [CONTENT_TYPE.participantDisplayNameUpdated]: CHAT_EVENTS.PARTICIPANT_DISPLAY_NAME_UPDATED, 138 | [CONTENT_TYPE.participantReturned]: CHAT_EVENTS.PARTICIPANT_RETURNED, 139 | [CONTENT_TYPE.participantInvited]: CHAT_EVENTS.PARTICIPANT_INVITED, 140 | [CONTENT_TYPE.autoDisconnection]: CHAT_EVENTS.AUTODISCONNECTION, 141 | [CONTENT_TYPE.chatRehydrated]: CHAT_EVENTS.CHAT_REHYDRATED, 142 | default: CHAT_EVENTS.INCOMING_MESSAGE 143 | }; 144 | 145 | export const EVENT = "EVENT"; 146 | export const MESSAGE = "MESSAGE"; 147 | export const CONN_ACK_FAILED = "CONN_ACK_FAILED"; 148 | 149 | export const TRANSCRIPT_DEFAULT_PARAMS = { 150 | MAX_RESULTS: 15, 151 | SORT_ORDER: "ASCENDING", 152 | SCAN_DIRECTION: "BACKWARD" 153 | }; 154 | 155 | export const LOGS_DESTINATION = { 156 | NULL: "NULL", 157 | CLIENT_LOGGER: "CLIENT_LOGGER", 158 | DEBUG: "DEBUG" 159 | }; 160 | 161 | export const REGIONS = { 162 | pdx: "us-west-2", 163 | iad: "us-east-1", 164 | syd: "ap-southeast-2", 165 | nrt: "ap-northeast-1", 166 | fra: "eu-central-1", 167 | pdt: "us-gov-west-1", 168 | yul: "ca-central-1", 169 | icn: "ap-northeast-2", 170 | cpt: "af-south-1" 171 | }; 172 | 173 | export const AGENT_RECONNECT_CONFIG = { 174 | interval: 3000, 175 | maxRetries: 5 176 | }; 177 | 178 | export const CUSTOMER_RECONNECT_CONFIG = { 179 | interval: 3000, 180 | maxRetries: 5 181 | }; 182 | 183 | export const CONNECTION_TOKEN_POLLING_INTERVAL_IN_MS = 1000 * 60 * 60 * 12; // 12 hours 184 | 185 | export const CONNECTION_TOKEN_EXPIRY_BUFFER_IN_MS = 60 * 1000; //1 min 186 | 187 | export const TRANSPORT_LIFETIME_IN_SECONDS = 3540; // 59 mins 188 | 189 | export const SEND_EVENT_CONACK_THROTTLED = "SEND_EVENT_CONACK_THROTTLED"; 190 | export const CREATE_PARTICIPANT_CONACK_FAILURE = "CREATE_PARTICIPANT_CONACK_FAILURE"; 191 | export const SEND_EVENT_CONACK_FAILURE = "SEND_EVENT_CONACK_FAILURE"; 192 | export const CREATE_PARTICIPANT_CONACK_API_CALL_COUNT = "CREATE_PARTICIPANT_CONACK_CALL_COUNT"; 193 | 194 | export const TYPING_VALIDITY_TIME = 10000; 195 | 196 | export const DUMMY_ENDED_EVENT = { 197 | AbsoluteTime: "", 198 | ContentType: "application/vnd.amazonaws.connect.event.chat.ended", 199 | Id: "", 200 | Type: "EVENT", 201 | InitialContactId: "" 202 | }; 203 | -------------------------------------------------------------------------------- /src/core/MessageReceiptsUtil.js: -------------------------------------------------------------------------------- 1 | import { CHAT_EVENTS } from '../constants'; 2 | import { GlobalConfig } from '../globalConfig'; 3 | import { LogManager } from '../log'; 4 | 5 | export default class MessageReceiptsUtil { 6 | constructor(logMetaData) { 7 | this.logger = LogManager.getLogger({ prefix: 'ChatJS-MessageReceiptUtil', logMetaData }); 8 | this.timeout = null; 9 | this.timeoutId = null; 10 | this.readSet = new Set(); 11 | this.deliveredSet = new Set(); 12 | this.readPromiseMap = new Map(); 13 | this.deliveredPromiseMap = new Map(); 14 | this.lastReadArgs = null; 15 | this.throttleInitialEventsToPrioritizeRead = null; 16 | this.throttleSendEventApiCall = null; 17 | } 18 | 19 | /** 20 | * check if message is of type read or delivered event 21 | * 22 | * @param {string} eventType either INCOMING_READ_RECEIPT or INCOMING_DELIVERED_RECEIPT. 23 | * @param {Object} incomingData object contains messageDetails 24 | * @return {boolean} returns true if read or delivered event else false 25 | */ 26 | isMessageReceipt(eventType, incomingData) { 27 | return [CHAT_EVENTS.INCOMING_READ_RECEIPT, CHAT_EVENTS.INCOMING_DELIVERED_RECEIPT] 28 | .indexOf(eventType) !== -1 || incomingData.Type === CHAT_EVENTS.MESSAGE_METADATA; 29 | } 30 | 31 | /** 32 | * check if message is for currentParticipantId 33 | * 34 | * @param {string} currentParticipantId of the contact 35 | * @param {Object} incomingData object contains messageDetails 36 | * @return {boolean} returns true if we need to display messageReceipt for the currentParticipantId 37 | * 38 | */ 39 | getEventTypeFromMessageMetaData(messageMetadata) { 40 | return Array.isArray(messageMetadata.Receipts) && 41 | messageMetadata.Receipts[0] && 42 | messageMetadata.Receipts[0].ReadTimestamp ? CHAT_EVENTS.INCOMING_READ_RECEIPT : 43 | messageMetadata.Receipts[0].DeliveredTimestamp ? CHAT_EVENTS.INCOMING_DELIVERED_RECEIPT : null; 44 | } 45 | 46 | /** 47 | * check if message is for currentParticipantId 48 | * 49 | * @param {string} currentParticipantId of the contact 50 | * @param {Object} incomingData object contains messageDetails 51 | * @return {boolean} returns true if we need to display messageReceipt for the currentParticipantId 52 | * 53 | */ 54 | shouldShowMessageReceiptForCurrentParticipantId(currentParticipantId, incomingData) { 55 | const recipientParticipantId = incomingData.MessageMetadata && 56 | Array.isArray(incomingData.MessageMetadata.Receipts) && 57 | incomingData.MessageMetadata.Receipts[0] && 58 | incomingData.MessageMetadata.Receipts[0].RecipientParticipantId; 59 | return currentParticipantId !== recipientParticipantId; 60 | } 61 | 62 | /** 63 | * Assumption: sendMessageReceipts are called in correct order of time the messages are Delivered or Read 64 | * Prioritize Read Event by Throttling Delivered events for 300ms but firing Read events immediately! 65 | * 66 | * @param {function} callback The callback fn to throttle and invoke. 67 | * @param {Array} args array of params [connectionToken, contentType, content, eventType, throttleTime] 68 | * @return {promise} returnPromise for Read and Delivered events 69 | */ 70 | prioritizeAndSendMessageReceipt(ChatClientContext, callback, ...args) { 71 | try { 72 | var self = this; 73 | var deliverEventThrottleTime = 300; 74 | var eventType = args[3]; 75 | var content = typeof args[2] === "string" ? JSON.parse(args[2]) : args[2]; 76 | var messageId = typeof content === "object" ? content.messageId : ""; 77 | 78 | //ignore repeat events - do not make sendEvent API call. 79 | if (self.readSet.has(messageId) || 80 | (eventType === CHAT_EVENTS.INCOMING_DELIVERED_RECEIPT && self.deliveredSet.has(messageId)) || 81 | !messageId) { 82 | this.logger.info(`Event already fired ${messageId}: sending messageReceipt ${eventType}`); 83 | return Promise.resolve({ 84 | message: 'Event already fired' 85 | }); 86 | } 87 | 88 | var resolve, reject; 89 | var returnPromise = new Promise(function(res,rej) { 90 | resolve = res; 91 | reject = rej; 92 | }); 93 | 94 | if (eventType === CHAT_EVENTS.INCOMING_DELIVERED_RECEIPT) { 95 | self.deliveredPromiseMap.set(messageId, [resolve, reject]); 96 | } else { 97 | self.readPromiseMap.set(messageId, [resolve, reject]); 98 | } 99 | 100 | self.throttleInitialEventsToPrioritizeRead = function() { 101 | // ignore Delivered event if Read event has been triggered for the current messageId 102 | if (eventType === CHAT_EVENTS.INCOMING_DELIVERED_RECEIPT) { 103 | self.deliveredSet.add(messageId); 104 | if (self.readSet.has(messageId)) { 105 | self.resolveDeliveredPromises(messageId, 'Event already fired'); 106 | return resolve({ 107 | message: 'Event already fired' 108 | }); 109 | } 110 | } 111 | if (self.readSet.has(messageId)) { 112 | self.resolveReadPromises(messageId, 'Event already fired'); 113 | return resolve({ 114 | message: 'Event already fired' 115 | }); 116 | } 117 | if (eventType === CHAT_EVENTS.INCOMING_READ_RECEIPT) { 118 | self.readSet.add(messageId); 119 | } 120 | 121 | if (content.disableThrottle) { 122 | this.logger.info(`throttleFn disabled for ${messageId}: sending messageReceipt ${eventType}`); 123 | return resolve(callback.call(ChatClientContext, ...args)); 124 | } 125 | self.logger.debug('call next throttleFn sendMessageReceipts', args); 126 | self.sendMessageReceipts.call(self, ChatClientContext, callback, ...args); 127 | }; 128 | 129 | if(!self.timeout) { 130 | self.timeout = setTimeout(function() { 131 | self.timeout = null; 132 | self.throttleInitialEventsToPrioritizeRead(); 133 | }, deliverEventThrottleTime); 134 | } 135 | 136 | //prevent multiple Read events for same messageId - call readEvent without delay 137 | if (eventType === CHAT_EVENTS.INCOMING_READ_RECEIPT && !self.readSet.has(messageId)) { 138 | clearTimeout(self.timeout); 139 | self.timeout = null; 140 | self.throttleInitialEventsToPrioritizeRead(); 141 | } 142 | 143 | return returnPromise; 144 | } catch (Err) { 145 | return Promise.reject({ 146 | message: "Failed to send messageReceipt", 147 | args, 148 | ...Err 149 | }); 150 | } 151 | } 152 | 153 | /** 154 | * Throttle for ${GlobalConfig.getMessageReceiptsThrottleTime()} and then fire Read and Delivered events 155 | * 156 | * @param {function} callback The callback fn to throttle and invoke. 157 | * @param {Array} args array of params [connectionToken, contentType, content, eventType, throttleTime] 158 | */ 159 | sendMessageReceipts(ChatClientContext, callback, ...args) { 160 | var self = this; 161 | var throttleTime = args[4] || GlobalConfig.getMessageReceiptsThrottleTime(); 162 | var eventType = args[3]; 163 | var content = typeof args[2] === "string" ? JSON.parse(args[2]) : args[2]; 164 | var messageId = content.messageId; 165 | this.lastReadArgs = eventType === CHAT_EVENTS.INCOMING_READ_RECEIPT ? args : this.lastReadArgs; 166 | 167 | self.throttleSendEventApiCall = function() { 168 | try { 169 | if(eventType === CHAT_EVENTS.INCOMING_READ_RECEIPT) { 170 | var sendEventPromise = callback.call(ChatClientContext, ...args); 171 | self.resolveReadPromises(messageId, sendEventPromise); 172 | self.logger.debug('send Read event:', callback, args); 173 | } else { 174 | //delivered event is the last event fired 175 | //fire delivered for latest messageId 176 | //fire read for latest messageId 177 | var PromiseArr = [callback.call(ChatClientContext, ...args)]; 178 | var contentVal = this.lastReadArgs ? (typeof this.lastReadArgs[2] === "string" ? JSON.parse(this.lastReadArgs[2]) : this.lastReadArgs[2]) : null; 179 | var readEventMessageId = contentVal && contentVal.messageId; 180 | // if readPromise has been resolved for readEventMessageId; readPromiseMap should not contain readEventMessageId 181 | // if readPromiseMap contains readEventMessageId; read event has not been called! 182 | if (self.readPromiseMap.has(readEventMessageId)) { 183 | PromiseArr.push(callback.call(ChatClientContext, ...this.lastReadArgs)); 184 | } 185 | 186 | self.logger.debug('send Delivered event:', args, 'read event:', this.lastReadArgs); 187 | Promise.allSettled(PromiseArr).then(res => { 188 | self.resolveDeliveredPromises(messageId, res[0].value || res[0].reason, res[0].status === "rejected"); 189 | // PromiseArr will have at most two promises (Delivered receipt promise and latest read receipt promise) 190 | // If result length is longer than 1, there must be a read receipt promise. 191 | if (readEventMessageId && res.length > 1) { 192 | self.resolveReadPromises(readEventMessageId, res[1].value || res[1].reason, res[1].status === "rejected"); 193 | } 194 | }); 195 | } 196 | } catch(err) { 197 | self.logger.error('send message receipt failed', err); 198 | self.resolveReadPromises(messageId, err, true); 199 | self.resolveDeliveredPromises(messageId, err, true); 200 | } 201 | }; 202 | 203 | if (!self.timeoutId) { 204 | self.timeoutId = setTimeout(function() { 205 | self.timeoutId = null; 206 | self.throttleSendEventApiCall(); 207 | }, throttleTime); 208 | } 209 | } 210 | 211 | /** 212 | * resolve All Delivered promises till messageId 213 | * 214 | * @param {string} messageId of the latest message receipt event 215 | * @param {Object} result of the latest message receipt event 216 | */ 217 | resolveDeliveredPromises(messageId, result, isError) { 218 | return this.resolvePromises(this.deliveredPromiseMap, messageId, result, isError); 219 | } 220 | 221 | /** 222 | * resolve All Read promises till messageId 223 | * 224 | * @param {string} messageId of the latest message receipt event 225 | * @param {Object} result of the latest message receipt event 226 | */ 227 | resolveReadPromises(messageId, result, isError) { 228 | return this.resolvePromises(this.readPromiseMap, messageId, result, isError); 229 | } 230 | 231 | /** 232 | * resolve All promises till messageId 233 | * 234 | * @param {Map} promiseMap of either send or delivered promises 235 | * @param {string} messageId of the latest message receipt event 236 | * @param {Object} result of the latest message receipt event 237 | */ 238 | resolvePromises(promiseMap, messageId, result, isError) { 239 | var arr = Array.from(promiseMap.keys()); 240 | var indexToResolve = arr.indexOf(messageId); 241 | 242 | if (indexToResolve !== -1) { 243 | for(let i=0;i<=indexToResolve;i++) { 244 | var callbackFn = promiseMap.get(arr[i])?.[ isError ? 1 : 0 ]; 245 | if (typeof callbackFn === 'function') { 246 | promiseMap.delete(arr[i]); 247 | callbackFn(result); 248 | } 249 | } 250 | } else { 251 | this.logger.debug(`Promise for messageId: ${messageId} already resolved`); 252 | } 253 | } 254 | 255 | /** 256 | * getTranscript API call should hydrate readSet and deliveredSet 257 | * 258 | * @param {function} callback to call with getTranscript response object. 259 | * @param {boolean} shouldSendMessageReceipts decides whether to hydrate mappers or not 260 | * @return {function} function which takes in input response from API call and calls callback with response. 261 | */ 262 | rehydrateReceiptMappers(callback, shouldSendMessageReceipts) { 263 | var self = this; 264 | return response => { 265 | self.logger.debug('rehydrate chat', response?.data); 266 | if (shouldSendMessageReceipts) { 267 | const { Transcript = [] } = response?.data || {}; 268 | Transcript.forEach(transcript => { 269 | if (transcript?.Type === CHAT_EVENTS.MESSAGE_METADATA) { 270 | const Receipt = transcript?.MessageMetadata?.Receipts?.[0]; 271 | const messageId = transcript?.MessageMetadata?.MessageId; 272 | if (Receipt?.ReadTimestamp) { 273 | this.readSet.add(messageId); 274 | } 275 | if (Receipt?.DeliveredTimestamp) { 276 | this.deliveredSet.add(messageId); 277 | } 278 | } 279 | }); 280 | } 281 | // send MessageReceipt for latest message is done by ChatInterface 282 | // UI will send Read receipt for the latest message displayed in the UI. 283 | return callback(response); 284 | }; 285 | } 286 | 287 | } 288 | -------------------------------------------------------------------------------- /src/core/MessageReceiptsUtil.spec.js: -------------------------------------------------------------------------------- 1 | import MessageReceiptsUtil from "./MessageReceiptsUtil"; 2 | import { CHAT_EVENTS, CONTENT_TYPE } from "../constants"; 3 | 4 | jest.mock("../log", () => ({ 5 | LogManager: { 6 | getLogger: () => console 7 | } 8 | })); 9 | describe("MessageReceiptsUtil", () => { 10 | const messageReceiptsUtil = new MessageReceiptsUtil({}); 11 | test("should initialize MessageReceiptsUtil correctly", () => { 12 | expect(messageReceiptsUtil.logger).toBeDefined(); 13 | expect(messageReceiptsUtil.timeout).toBeNull(); 14 | expect(messageReceiptsUtil.timeoutId).toBeNull(); 15 | expect(messageReceiptsUtil.readMap).not.toBeNull(); 16 | expect(messageReceiptsUtil.deliveredMap).not.toBeNull(); 17 | expect(messageReceiptsUtil.readPromiseMap).not.toBeNull(); 18 | expect(messageReceiptsUtil.readPromiseMap).not.toBeNull(); 19 | }); 20 | 21 | test("should return true if messageType Read or Delivered receipt", () => { 22 | expect(messageReceiptsUtil.isMessageReceipt("INCOMING_READ_RECEIPT")).toBeTruthy(); 23 | expect(messageReceiptsUtil.isMessageReceipt("INCOMING_DELIVERED_RECEIPT")).toBeTruthy(); 24 | expect(messageReceiptsUtil.isMessageReceipt("", { Type: "MESSAGEMETADATA" })).toBeTruthy(); 25 | }); 26 | 27 | test("should return true if currentParticipantId not equal to recipientId", () => { 28 | expect(messageReceiptsUtil.shouldShowMessageReceiptForCurrentParticipantId("p1", { 29 | MessageMetadata: { 30 | Receipts: [{ 31 | RecipientParticipantId: "p2" 32 | }] 33 | } 34 | })).toBeTruthy(); 35 | expect(messageReceiptsUtil.shouldShowMessageReceiptForCurrentParticipantId("p2", { 36 | MessageMetadata: { 37 | Receipts: [{ 38 | RecipientParticipantId: "p2" 39 | }] 40 | } 41 | })).toBeFalsy(); 42 | }); 43 | test("should throttle and catch error if callback fails", (done) => { 44 | jest.useRealTimers(); 45 | const customError = new Error("Test"); 46 | const callback = jest.fn().mockImplementation(() => { 47 | throw customError; 48 | }); 49 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, "token", 50 | CONTENT_TYPE.readReceipt, `{"messageId":"messageId222"}`, 51 | CHAT_EVENTS.INCOMING_READ_RECEIPT, 1000) 52 | .then((res) => console.log("resolve", res)) 53 | .catch(err => { 54 | expect(err).toEqual(customError); 55 | done(); 56 | }); 57 | }); 58 | test("should not throttle if throttling is disabled for the event", (done) => { 59 | jest.useRealTimers(); 60 | const callback = jest.fn().mockImplementation(() => Promise.resolve("event_processed")); 61 | const p1 = messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, "token", 62 | CONTENT_TYPE.readReceipt, `{"messageId":"messageId2221", "disableThrottle": true}`, 63 | CHAT_EVENTS.INCOMING_READ_RECEIPT, 1000); 64 | const p2 = messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, "token", 65 | CONTENT_TYPE.readReceipt, `{"messageId":"messageId2221", "disableThrottle": true}`, 66 | CHAT_EVENTS.INCOMING_READ_RECEIPT, 1000); 67 | const p3 = messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, "token", 68 | CONTENT_TYPE.readReceipt, `{}`, 69 | CHAT_EVENTS.INCOMING_READ_RECEIPT, 1000); 70 | Promise.all([p1,p2,p3]).then(res => { 71 | expect(res[0]).toEqual("event_processed"); 72 | expect(res[1]).toEqual({ 73 | message: 'Event already fired' 74 | }); 75 | expect(res[2]).toEqual({ 76 | message: 'Event already fired' 77 | }); 78 | done(); 79 | }); 80 | }); 81 | test("should throttle and call callback once", async () => { 82 | jest.useRealTimers(); 83 | const callback = jest.fn(); 84 | const args = ["token", CONTENT_TYPE.deliveredReceipt, 85 | `{"messageId":"messageId11"}`, CHAT_EVENTS.INCOMING_DELIVERED_RECEIPT, 1000]; 86 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, ...args); 87 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, ...args); 88 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, ...args); 89 | await messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, "token", 90 | CONTENT_TYPE.readReceipt, `{"messageId":"messageId21"}`, 91 | CHAT_EVENTS.INCOMING_READ_RECEIPT, 1000); 92 | expect(callback).toHaveBeenCalledTimes(1); 93 | 94 | await messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, "token", 95 | CONTENT_TYPE.deliveredReceipt, `{"messageId":"messageId21"}`, 96 | CHAT_EVENTS.INCOMING_DELIVERED_RECEIPT, 1000); 97 | expect(callback).toHaveBeenCalledTimes(1); 98 | }); 99 | test("should throttle and call callback twice", (done) => { 100 | jest.useRealTimers(); 101 | const callback = jest.fn().mockImplementation(() => Promise.resolve("test")); 102 | const args = ["token", CONTENT_TYPE.deliveredReceipt, 103 | `{"messageId":"message1"}`, CHAT_EVENTS.INCOMING_DELIVERED_RECEIPT, 1000]; 104 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, ...args); 105 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, ...args); 106 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, ...args); 107 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, "token", 108 | CONTENT_TYPE.readReceipt, `{"messageId":"message2"}`, 109 | CHAT_EVENTS.INCOMING_READ_RECEIPT, 1000).then(() => { 110 | setTimeout(() => { 111 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, "token", 112 | CONTENT_TYPE.readReceipt, `{"messageId":"message22"}`, 113 | CHAT_EVENTS.INCOMING_READ_RECEIPT, 1000).then(() => { 114 | expect(callback).toHaveBeenCalledTimes(2); 115 | done(); 116 | }); 117 | }, 1500); 118 | }); 119 | }); 120 | test("should throttle and call callback thrice", (done) => { 121 | jest.useRealTimers(); 122 | const callback = jest.fn().mockImplementation(() => Promise.resolve("test")); 123 | const args = ["token", CONTENT_TYPE.deliveredReceipt, 124 | `{"messageId":"mess1"}`, CHAT_EVENTS.INCOMING_DELIVERED_RECEIPT, 1000]; 125 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, ...args); 126 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, ...args); 127 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, ...args); 128 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, "token", 129 | CONTENT_TYPE.readReceipt, `{"messageId":"mess2"}`, 130 | CHAT_EVENTS.INCOMING_READ_RECEIPT, 1000).then(() => { 131 | setTimeout(() => { 132 | messageReceiptsUtil.throttleInitialEventsToPrioritizeRead(); 133 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, ...args); 134 | messageReceiptsUtil.readSet.add("mess1"); 135 | messageReceiptsUtil.throttleInitialEventsToPrioritizeRead(); 136 | Promise.all([ 137 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, "token", 138 | CONTENT_TYPE.readReceipt, `{"messageId":"mess3"}`, 139 | CHAT_EVENTS.INCOMING_READ_RECEIPT, 1000), 140 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, "token", 141 | CONTENT_TYPE.deliveredReceipt, `{"messageId":"mess4"}`, 142 | CHAT_EVENTS.INCOMING_DELIVERED_RECEIPT, 1000), 143 | messageReceiptsUtil.prioritizeAndSendMessageReceipt(this, callback, "token", 144 | CONTENT_TYPE.deliveredReceipt, `{"messageId":"mess5"}`, 145 | CHAT_EVENTS.INCOMING_DELIVERED_RECEIPT, 1000)]).then(() => { 146 | expect(callback).toHaveBeenCalledTimes(3); 147 | done(); 148 | }); 149 | }, 1500); 150 | }); 151 | }); 152 | 153 | test("should rehydrate mappers", () => { 154 | const callback = jest.fn(); 155 | const response = { 156 | data: { 157 | Transcript: [{ 158 | "Id": "sampleId3", 159 | "Type": "MESSAGEMETADATA", 160 | "MessageMetadata": { 161 | "MessageId": "messageIdABC", 162 | "Receipts": [ 163 | { 164 | "DeliverTimestamp": "2022-06-25T00:09:15.864Z", 165 | "ReadTimestamp": "2022-06-25T00:09:18.864Z", 166 | "RecipientParticipantId": "participantDEF", 167 | } 168 | ] 169 | } 170 | }] 171 | } 172 | }; 173 | messageReceiptsUtil.rehydrateReceiptMappers(callback, true)(response); 174 | expect(callback).toHaveBeenCalledTimes(1); 175 | expect(callback).toHaveBeenCalledWith(response); 176 | }); 177 | test("should return an error", async () => { 178 | const callback = jest.fn(); 179 | const context = null; 180 | try { 181 | const returnVal = await messageReceiptsUtil.prioritizeAndSendMessageReceipt.call(context, callback, {}); 182 | console.log("returnVal", returnVal); 183 | expect(returnVal).toEqual({ 184 | "args": [], 185 | "message": "Failed to send messageReceipt", 186 | }); 187 | } catch (err) { 188 | expect(err).toEqual({ 189 | "args": [], 190 | "message": "Failed to send messageReceipt", 191 | }); 192 | } 193 | }); 194 | }); -------------------------------------------------------------------------------- /src/core/chatArgsValidator.js: -------------------------------------------------------------------------------- 1 | import Utils from "../utils"; 2 | import { IllegalArgumentException } from "./exceptions"; 3 | import { CONTENT_TYPE, SESSION_TYPES } from "../constants"; 4 | 5 | class ChatControllerArgsValidator { 6 | /*eslint-disable no-unused-vars*/ 7 | validateNewControllerDetails(chatDetails) { 8 | return true; 9 | } 10 | /*eslint-enable no-unused-vars*/ 11 | 12 | validateSendMessage(args) { 13 | if (!Utils.isString(args.message)) { 14 | throw new IllegalArgumentException(args.message + "is not a valid message"); 15 | } 16 | this.validateContentType(args.contentType); 17 | } 18 | 19 | validateContentType(contentType) { 20 | Utils.assertIsEnum(contentType, Object.values(CONTENT_TYPE), "contentType"); 21 | } 22 | 23 | /*eslint-disable no-unused-vars*/ 24 | validateConnectChat(args) { 25 | return true; 26 | } 27 | /*eslint-enable no-unused-vars*/ 28 | 29 | validateLogger(logger) { 30 | Utils.assertIsObject(logger, "logger"); 31 | ["debug", "info", "warn", "error"].forEach(methodName => { 32 | if (!Utils.isFunction(logger[methodName])) { 33 | throw new IllegalArgumentException( 34 | methodName + 35 | " should be a valid function on the passed logger object!" 36 | ); 37 | } 38 | }); 39 | } 40 | 41 | validateSendEvent(args) { 42 | this.validateContentType(args.contentType); 43 | } 44 | 45 | /*eslint-disable no-unused-vars*/ 46 | validateGetMessages(args) { 47 | return true; 48 | } 49 | /*eslint-enable no-unused-vars*/ 50 | } 51 | 52 | class ChatServiceArgsValidator extends ChatControllerArgsValidator { 53 | validateChatDetails(chatDetails, sessionType) { 54 | Utils.assertIsObject(chatDetails, "chatDetails"); 55 | if (sessionType===SESSION_TYPES.AGENT && !Utils.isFunction(chatDetails.getConnectionToken)) { 56 | throw new IllegalArgumentException( 57 | "getConnectionToken was not a function", 58 | chatDetails.getConnectionToken 59 | ); 60 | } 61 | Utils.assertIsNonEmptyString( 62 | chatDetails.contactId, 63 | "chatDetails.contactId" 64 | ); 65 | Utils.assertIsNonEmptyString( 66 | chatDetails.participantId, 67 | "chatDetails.participantId" 68 | ); 69 | if (sessionType===SESSION_TYPES.CUSTOMER){ 70 | if (chatDetails.participantToken){ 71 | Utils.assertIsNonEmptyString( 72 | chatDetails.participantToken, 73 | "chatDetails.participantToken" 74 | ); 75 | } else { 76 | throw new IllegalArgumentException( 77 | "participantToken was not provided for a customer session type", 78 | chatDetails.participantToken 79 | ); 80 | } 81 | } 82 | } 83 | 84 | validateInitiateChatResponse() { 85 | return true; 86 | } 87 | 88 | normalizeChatDetails(chatDetailsInput) { 89 | let chatDetails = {}; 90 | chatDetails.contactId = chatDetailsInput.ContactId || chatDetailsInput.contactId; 91 | chatDetails.participantId = chatDetailsInput.ParticipantId || chatDetailsInput.participantId; 92 | chatDetails.initialContactId = chatDetailsInput.InitialContactId || chatDetailsInput.initialContactId 93 | || chatDetails.contactId || chatDetails.ContactId; 94 | chatDetails.getConnectionToken = chatDetailsInput.getConnectionToken || chatDetailsInput.GetConnectionToken; 95 | if (chatDetailsInput.participantToken || chatDetailsInput.ParticipantToken) { 96 | chatDetails.participantToken = chatDetailsInput.ParticipantToken || chatDetailsInput.participantToken; 97 | } 98 | this.validateChatDetails(chatDetails); 99 | return chatDetails; 100 | } 101 | } 102 | 103 | export { ChatServiceArgsValidator }; 104 | -------------------------------------------------------------------------------- /src/core/chatArgsValidator.spec.js: -------------------------------------------------------------------------------- 1 | import { ChatServiceArgsValidator } from "./chatArgsValidator"; 2 | import { IllegalArgumentException } from "./exceptions"; 3 | 4 | describe("ChatServiceArgsValidator", () => { 5 | 6 | function getValidator() { 7 | return new ChatServiceArgsValidator(); 8 | } 9 | let chatDetailsInput; 10 | let expectedChatDetails; 11 | let chatDetails; 12 | let getConnectionToken = jest.fn(); 13 | 14 | test("chatDetails w/o participantToken or connectionDetails normalized as expected", async () => { 15 | const chatArgsValidator = getValidator(); 16 | chatDetailsInput = { 17 | ContactId: "cid", 18 | ParticipantId: "pid", 19 | InitialContactId: "icid", 20 | getConnectionToken: getConnectionToken 21 | }; 22 | expectedChatDetails = { 23 | contactId: "cid", 24 | participantId: "pid", 25 | initialContactId: "icid", 26 | getConnectionToken: getConnectionToken 27 | }; 28 | chatDetails = chatArgsValidator.normalizeChatDetails(chatDetailsInput); 29 | expect(chatDetails).toEqual(expectedChatDetails); 30 | }); 31 | 32 | test("chatDetails w/ participantToken, w/o connectionDetails normalized as expected", async () => { 33 | const chatArgsValidator = getValidator(); 34 | chatDetailsInput = { 35 | contactId: "cid", 36 | participantId: "pid", 37 | ParticipantToken: "ptoken" 38 | }; 39 | expectedChatDetails = { 40 | contactId: "cid", 41 | participantId: "pid", 42 | initialContactId: "cid", 43 | participantToken: "ptoken" 44 | }; 45 | chatDetails = chatArgsValidator.normalizeChatDetails(chatDetailsInput); 46 | expect(chatDetails).toEqual(expectedChatDetails); 47 | }); 48 | 49 | test("validateSendEvent only passes on valid content types", () => { 50 | const sendEventRequest = { 51 | contentType: "application/vnd.amazonaws.connect.event.participant.inactive" 52 | }; 53 | const chatArgsValidator = getValidator(); 54 | chatArgsValidator.validateSendEvent(sendEventRequest); 55 | 56 | sendEventRequest.contentType = "application/vnd.amazonaws.connect.event.participant.disengaged"; 57 | const validateCall = () => chatArgsValidator.validateSendEvent(sendEventRequest); 58 | expect(validateCall).toThrow(IllegalArgumentException); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/core/chatSession.js: -------------------------------------------------------------------------------- 1 | import { 2 | UnImplementedMethodException, 3 | IllegalArgumentException 4 | } from "./exceptions"; 5 | import { ChatClientFactory } from "../client/client"; 6 | import { ChatServiceArgsValidator } from "./chatArgsValidator"; 7 | import { SESSION_TYPES, CHAT_EVENTS, FEATURES } from "../constants"; 8 | import { GlobalConfig } from "../globalConfig"; 9 | import { ChatController } from "./chatController"; 10 | import { LogManager, LogLevel, Logger } from "../log"; 11 | import { csmService } from "../service/csmService"; 12 | import WebSocketManager from "../lib/amazon-connect-websocket-manager"; 13 | 14 | const logger = LogManager.getLogger({ prefix: "ChatJS-GlobalConfig" }); 15 | 16 | class ChatSessionFactory { 17 | /*eslint-disable no-unused-vars*/ 18 | 19 | createAgentChatController(chatDetails, participantType) { 20 | throw new UnImplementedMethodException( 21 | "createAgentChatController in ChatControllerFactory." 22 | ); 23 | } 24 | 25 | createCustomerChatController(chatDetails, participantType) { 26 | throw new UnImplementedMethodException( 27 | "createCustomerChatController in ChatControllerFactory." 28 | ); 29 | } 30 | /*eslint-enable no-unused-vars*/ 31 | } 32 | 33 | class PersistentConnectionAndChatServiceSessionFactory extends ChatSessionFactory { 34 | constructor() { 35 | super(); 36 | this.argsValidator = new ChatServiceArgsValidator(); 37 | } 38 | 39 | createChatSession(sessionType, chatDetails, options, websocketManager) { 40 | const chatController = this._createChatController(sessionType, chatDetails, options, websocketManager); 41 | if (sessionType === SESSION_TYPES.AGENT) { 42 | return new AgentChatSession(chatController); 43 | } else if (sessionType === SESSION_TYPES.CUSTOMER) { 44 | return new CustomerChatSession(chatController); 45 | } else { 46 | throw new IllegalArgumentException( 47 | "Unkown value for session type, Allowed values are: " + 48 | Object.values(SESSION_TYPES), 49 | sessionType 50 | ); 51 | } 52 | } 53 | 54 | _createChatController(sessionType, chatDetailsInput, options, websocketManager) { 55 | var chatDetails = this.argsValidator.normalizeChatDetails(chatDetailsInput); 56 | var logMetaData = { 57 | contactId: chatDetails.contactId, 58 | participantId: chatDetails.participantId, 59 | sessionType 60 | }; 61 | 62 | var chatClient = ChatClientFactory.getCachedClient(options, logMetaData); 63 | 64 | var args = { 65 | sessionType: sessionType, 66 | chatDetails, 67 | chatClient, 68 | websocketManager: websocketManager, 69 | logMetaData, 70 | }; 71 | 72 | return new ChatController(args); 73 | } 74 | } 75 | 76 | export class ChatSession { 77 | constructor(controller) { 78 | this.controller = controller; 79 | } 80 | 81 | onMessage(callback) { 82 | this.controller.subscribe(CHAT_EVENTS.INCOMING_MESSAGE, callback); 83 | } 84 | 85 | onTyping(callback) { 86 | this.controller.subscribe(CHAT_EVENTS.INCOMING_TYPING, callback); 87 | } 88 | 89 | onReadReceipt(callback) { 90 | this.controller.subscribe(CHAT_EVENTS.INCOMING_READ_RECEIPT, callback); 91 | } 92 | 93 | onDeliveredReceipt(callback) { 94 | this.controller.subscribe(CHAT_EVENTS.INCOMING_DELIVERED_RECEIPT, callback); 95 | } 96 | 97 | onConnectionBroken(callback) { 98 | this.controller.subscribe(CHAT_EVENTS.CONNECTION_BROKEN, callback); 99 | } 100 | 101 | onConnectionEstablished(callback) { 102 | this.controller.subscribe(CHAT_EVENTS.CONNECTION_ESTABLISHED, callback); 103 | } 104 | 105 | onEnded(callback) { 106 | this.controller.subscribe(CHAT_EVENTS.CHAT_ENDED, callback); 107 | } 108 | 109 | onParticipantIdle(callback) { 110 | this.controller.subscribe(CHAT_EVENTS.PARTICIPANT_IDLE, callback); 111 | } 112 | 113 | onParticipantReturned(callback) { 114 | this.controller.subscribe(CHAT_EVENTS.PARTICIPANT_RETURNED, callback); 115 | } 116 | 117 | onParticipantInvited(callback) { 118 | this.controller.subscribe(CHAT_EVENTS.PARTICIPANT_INVITED, callback); 119 | } 120 | 121 | onAutoDisconnection(callback) { 122 | this.controller.subscribe(CHAT_EVENTS.AUTODISCONNECTION, callback); 123 | } 124 | 125 | onConnectionLost(callback) { 126 | this.controller.subscribe(CHAT_EVENTS.CONNECTION_LOST, callback); 127 | } 128 | 129 | onDeepHeartbeatSuccess(callback){ 130 | this.controller.subscribe(CHAT_EVENTS.DEEP_HEARTBEAT_SUCCESS, callback); 131 | } 132 | 133 | onDeepHeartbeatFailure(callback){ 134 | this.controller.subscribe(CHAT_EVENTS.DEEP_HEARTBEAT_FAILURE, callback); 135 | } 136 | 137 | onAuthenticationInitiated(callback) { 138 | this.controller.subscribe(CHAT_EVENTS.AUTHENTICATION_INITIATED, callback); 139 | } 140 | 141 | onAuthenticationSuccessful(callback) { 142 | this.controller.subscribe(CHAT_EVENTS.AUTHENTICATION_SUCCESSFUL, callback); 143 | } 144 | 145 | onAuthenticationFailed(callback) { 146 | this.controller.subscribe(CHAT_EVENTS.AUTHENTICATION_FAILED, callback); 147 | } 148 | 149 | onAuthenticationTimeout(callback) { 150 | this.controller.subscribe(CHAT_EVENTS.AUTHENTICATION_TIMEOUT, callback); 151 | } 152 | 153 | onAuthenticationExpired(callback) { 154 | this.controller.subscribe(CHAT_EVENTS.AUTHENTICATION_EXPIRED, callback); 155 | } 156 | 157 | onAuthenticationCanceled(callback) { 158 | this.controller.subscribe(CHAT_EVENTS.AUTHENTICATION_CANCELED, callback); 159 | } 160 | 161 | onParticipantDisplayNameUpdated(callback) { 162 | this.controller.subscribe(CHAT_EVENTS.PARTICIPANT_DISPLAY_NAME_UPDATED, callback); 163 | } 164 | 165 | onChatRehydrated(callback) { 166 | this.controller.subscribe(CHAT_EVENTS.CHAT_REHYDRATED, callback); 167 | } 168 | 169 | sendMessage(args) { 170 | return this.controller.sendMessage(args); 171 | } 172 | 173 | sendAttachment(args){ 174 | return this.controller.sendAttachment(args); 175 | } 176 | 177 | downloadAttachment(args){ 178 | return this.controller.downloadAttachment(args); 179 | } 180 | 181 | connect(args) { 182 | return this.controller.connect(args); 183 | } 184 | 185 | sendEvent(args) { 186 | return this.controller.sendEvent(args); 187 | } 188 | 189 | getTranscript(args) { 190 | return this.controller.getTranscript(args); 191 | } 192 | 193 | getChatDetails() { 194 | return this.controller.getChatDetails(); 195 | } 196 | 197 | describeView(args) { 198 | return this.controller.describeView(args); 199 | } 200 | 201 | getAuthenticationUrl(args) { 202 | return this.controller.getAuthenticationUrl(args); 203 | } 204 | 205 | cancelParticipantAuthentication(args) { 206 | return this.controller.cancelParticipantAuthentication(args); 207 | } 208 | } 209 | 210 | class AgentChatSession extends ChatSession { 211 | constructor(controller) { 212 | super(controller); 213 | } 214 | 215 | cleanUpOnParticipantDisconnect() { 216 | return this.controller.cleanUpOnParticipantDisconnect(); 217 | } 218 | } 219 | 220 | class CustomerChatSession extends ChatSession { 221 | constructor(controller) { 222 | super(controller); 223 | } 224 | 225 | disconnectParticipant() { 226 | return this.controller.disconnectParticipant(); 227 | } 228 | } 229 | 230 | export const CHAT_SESSION_FACTORY = new PersistentConnectionAndChatServiceSessionFactory(); 231 | 232 | var setGlobalConfig = config => { 233 | var loggerConfig = config.loggerConfig; 234 | var csmConfig = config.csmConfig; 235 | GlobalConfig.update(config); 236 | /** 237 | * if config.loggerConfig.logger is present - use it in websocketManager 238 | * if config.loggerConfig.customizedLogger is present - use it in websocketManager 239 | * if config.loggerConfig.useDefaultLogger is true - use default window.console + default level INFO 240 | * config.loggerConfig.advancedLogWriter to customize where you want to log advancedLog messages. Default is warn. 241 | * else no logs from websocketManager - DEFAULT 242 | * 243 | * if config.webSocketManagerConfig.isNetworkOnline is present - use it in websocketManager 244 | * else websocketManager uses "navigator.onLine" - DEFAULT 245 | */ 246 | WebSocketManager.setGlobalConfig(config); 247 | LogManager.updateLoggerConfig(loggerConfig); 248 | if (csmConfig) { 249 | csmService.updateCsmConfig(csmConfig); 250 | } 251 | /** 252 | * Handle setting message receipts feature in Global Config. If no values are given will default to: 253 | * - Message receipts enabled 254 | * - Throttle = 5000 ms 255 | */ 256 | logger.warn("enabling message-receipts by default; to disable set config.features.messageReceipts.shouldSendMessageReceipts = false"); 257 | GlobalConfig.updateThrottleTime(config.features?.messageReceipts?.throttleTime); 258 | if (config.features?.messageReceipts?.shouldSendMessageReceipts === false) { 259 | GlobalConfig.removeFeatureFlag(FEATURES.MESSAGE_RECEIPTS_ENABLED); 260 | } else { 261 | /** 262 | * Note: if update config is called with `config.features` array, it replaces default config 263 | * this ensure `message-receipts` feature is always enabled. 264 | * Only way to disable message receipts is by setting config.features.messageReceipts.shouldSendMessageReceipts == false 265 | * */ 266 | setFeatureFlag(FEATURES.MESSAGE_RECEIPTS_ENABLED); 267 | } 268 | }; 269 | 270 | var setFeatureFlag = feature => { 271 | GlobalConfig.setFeatureFlag(feature); 272 | }; 273 | 274 | var ChatSessionConstructor = args => { 275 | var options = args.options || {}; 276 | var type = args.type || SESSION_TYPES.AGENT; 277 | GlobalConfig.updateStageRegionCell(options); 278 | // initialize CSM Service for only customer chat widget 279 | // Disable CSM service from canary test 280 | if(!args.disableCSM && type === SESSION_TYPES.CUSTOMER) { 281 | csmService.loadCsmScriptAndExecute(); 282 | } 283 | return CHAT_SESSION_FACTORY.createChatSession( 284 | type, 285 | args.chatDetails, 286 | options,//options contain region 287 | args.websocketManager, 288 | ); 289 | }; 290 | 291 | var setRegionOverride = regionOverride => { 292 | GlobalConfig.updateRegionOverride(regionOverride); 293 | }; 294 | 295 | const ChatSessionObject = { 296 | create: ChatSessionConstructor, 297 | setGlobalConfig: setGlobalConfig, 298 | LogLevel: LogLevel, 299 | Logger: Logger, 300 | SessionTypes: SESSION_TYPES, 301 | csmService: csmService, 302 | setFeatureFlag: setFeatureFlag, 303 | setRegionOverride: setRegionOverride 304 | }; 305 | 306 | export { ChatSessionObject }; 307 | -------------------------------------------------------------------------------- /src/core/chatSession.spec.js: -------------------------------------------------------------------------------- 1 | import { ChatSession, ChatSessionObject } from "./chatSession"; 2 | import { csmService } from "../service/csmService"; 3 | import { CHAT_SESSION_FACTORY } from "./chatSession"; 4 | import { ChatController } from "./chatController"; 5 | import { SESSION_TYPES, CHAT_EVENTS } from "../constants"; 6 | import { GlobalConfig } from "../globalConfig"; 7 | describe("CSM", () => { 8 | 9 | beforeEach(() => { 10 | jest.resetAllMocks(); 11 | jest.spyOn(csmService, 'initializeCSM').mockImplementation(() => {}); 12 | jest.spyOn(CHAT_SESSION_FACTORY, 'createChatSession').mockImplementation(() => {}); 13 | jest.spyOn(GlobalConfig, 'updateRegionOverride').mockImplementation(() => {}); 14 | }); 15 | 16 | afterAll(() => { 17 | jest.resetAllMocks(); 18 | }); 19 | 20 | test("should initialize csm for customer sessions", async () => { 21 | const args = { 22 | type: SESSION_TYPES.CUSTOMER 23 | }; 24 | ChatSessionObject.create(args); 25 | expect(CHAT_SESSION_FACTORY.createChatSession).toHaveBeenCalled(); 26 | expect(csmService.initializeCSM).toHaveBeenCalled(); 27 | }); 28 | 29 | test("should call region override", async () => { 30 | ChatSessionObject.setRegionOverride('test'); 31 | expect(GlobalConfig.updateRegionOverride).toHaveBeenCalled(); 32 | }); 33 | 34 | test("should not initialize csm for non-customer sessions", async () => { 35 | const args = { 36 | type: SESSION_TYPES.AGENT 37 | }; 38 | ChatSessionObject.create(args); 39 | expect(CHAT_SESSION_FACTORY.createChatSession).toHaveBeenCalled(); 40 | expect(csmService.initializeCSM).not.toHaveBeenCalled(); 41 | }); 42 | 43 | test("should not initialize csm when session type is missing", async () => { 44 | const args = {}; 45 | ChatSessionObject.create(args); 46 | expect(CHAT_SESSION_FACTORY.createChatSession).toHaveBeenCalled(); 47 | expect(csmService.initializeCSM).not.toHaveBeenCalled(); 48 | }); 49 | 50 | test("should not initialize csm when disableCSM flag is true", () => { 51 | const args = {disableCSM: true}; 52 | ChatSessionObject.create(args); 53 | expect(CHAT_SESSION_FACTORY.createChatSession).toHaveBeenCalled(); 54 | expect(csmService.initializeCSM).not.toHaveBeenCalled(); 55 | }); 56 | }); 57 | 58 | describe("chatSession", () => { 59 | const chatDetails = {}; 60 | let chatClient = {}; 61 | const websocketManager = {}; 62 | 63 | function getChatController() { 64 | return new ChatController({ 65 | sessionType: SESSION_TYPES.AGENT, 66 | chatDetails: chatDetails, 67 | chatClient: chatClient, 68 | websocketManager: websocketManager, 69 | }); 70 | } 71 | const controller = getChatController(); 72 | const session = new ChatSession(controller); 73 | test('event listener', async () => { 74 | const eventData = { 75 | data: "", 76 | chatDetails 77 | }; 78 | const cb1 = jest.fn(); 79 | const cb2 = jest.fn(); 80 | const cb3 = jest.fn(); 81 | const cb4 = jest.fn(); 82 | const cb5 = jest.fn(); 83 | const cb6 = jest.fn(); 84 | const cb7 = jest.fn(); 85 | const cb8 = jest.fn(); 86 | const cb9 = jest.fn(); 87 | const cb10 = jest.fn(); 88 | const cb11 = jest.fn(); 89 | const cb12 = jest.fn(); 90 | const cb13 = jest.fn(); 91 | const cb14 = jest.fn(); 92 | const cb15 = jest.fn(); 93 | const cb16 = jest.fn(); 94 | const cb17 = jest.fn(); 95 | const cb18 = jest.fn(); 96 | const cb19 = jest.fn(); 97 | const cb20 = jest.fn(); 98 | const cb21 = jest.fn(); 99 | const cb22 = jest.fn(); 100 | 101 | session.onParticipantIdle(cb1); 102 | session.onParticipantReturned(cb2); 103 | session.onAutoDisconnection(cb3); 104 | session.onMessage(cb4); 105 | session.onTyping(cb5); 106 | session.onReadReceipt(cb6); 107 | session.onDeliveredReceipt(cb7); 108 | session.onConnectionBroken(cb8); 109 | session.onConnectionEstablished(cb9); 110 | session.onEnded(cb10); 111 | session.onConnectionLost(cb11); 112 | session.onDeepHeartbeatSuccess(cb12); 113 | session.onDeepHeartbeatFailure(cb13); 114 | session.onAuthenticationInitiated(cb14); 115 | session.onChatRehydrated(cb15); 116 | session.onAuthenticationFailed(cb16); 117 | session.onAuthenticationTimeout(cb17); 118 | session.onAuthenticationExpired(cb18); 119 | session.onAuthenticationCanceled(cb19); 120 | session.onAuthenticationSuccessful(cb20); 121 | session.onParticipantDisplayNameUpdated(cb21); 122 | session.onParticipantInvited(cb22); 123 | 124 | controller._forwardChatEvent(CHAT_EVENTS.PARTICIPANT_IDLE, eventData); 125 | controller._forwardChatEvent(CHAT_EVENTS.PARTICIPANT_RETURNED, eventData); 126 | controller._forwardChatEvent(CHAT_EVENTS.PARTICIPANT_INVITED, eventData); 127 | controller._forwardChatEvent(CHAT_EVENTS.AUTODISCONNECTION, eventData); 128 | controller._forwardChatEvent(CHAT_EVENTS.INCOMING_MESSAGE, eventData); 129 | controller._forwardChatEvent(CHAT_EVENTS.INCOMING_TYPING, eventData); 130 | controller._forwardChatEvent(CHAT_EVENTS.INCOMING_READ_RECEIPT, eventData); 131 | controller._forwardChatEvent(CHAT_EVENTS.INCOMING_DELIVERED_RECEIPT, eventData); 132 | controller._forwardChatEvent(CHAT_EVENTS.CONNECTION_BROKEN, eventData); 133 | controller._forwardChatEvent(CHAT_EVENTS.CONNECTION_ESTABLISHED, eventData); 134 | controller._forwardChatEvent(CHAT_EVENTS.CHAT_ENDED, eventData); 135 | controller._forwardChatEvent(CHAT_EVENTS.CONNECTION_LOST, eventData); 136 | controller._forwardChatEvent(CHAT_EVENTS.DEEP_HEARTBEAT_SUCCESS, eventData); 137 | controller._forwardChatEvent(CHAT_EVENTS.DEEP_HEARTBEAT_FAILURE, eventData); 138 | controller._forwardChatEvent(CHAT_EVENTS.AUTHENTICATION_INITIATED, eventData); 139 | controller._forwardChatEvent(CHAT_EVENTS.AUTHENTICATION_SUCCESSFUL, eventData); 140 | controller._forwardChatEvent(CHAT_EVENTS.AUTHENTICATION_FAILED, eventData); 141 | controller._forwardChatEvent(CHAT_EVENTS.AUTHENTICATION_TIMEOUT, eventData); 142 | controller._forwardChatEvent(CHAT_EVENTS.AUTHENTICATION_EXPIRED, eventData); 143 | controller._forwardChatEvent(CHAT_EVENTS.AUTHENTICATION_CANCELED, eventData); 144 | controller._forwardChatEvent(CHAT_EVENTS.PARTICIPANT_DISPLAY_NAME_UPDATED, eventData); 145 | controller._forwardChatEvent(CHAT_EVENTS.CHAT_REHYDRATED, eventData); 146 | 147 | await new Promise((r) => setTimeout(r, 0)); 148 | 149 | expect(cb1).toHaveBeenCalled(); 150 | expect(cb2).toHaveBeenCalled(); 151 | expect(cb3).toHaveBeenCalled(); 152 | expect(cb4).toHaveBeenCalled(); 153 | expect(cb5).toHaveBeenCalled(); 154 | expect(cb6).toHaveBeenCalled(); 155 | expect(cb7).toHaveBeenCalled(); 156 | expect(cb8).toHaveBeenCalled(); 157 | expect(cb9).toHaveBeenCalled(); 158 | expect(cb10).toHaveBeenCalled(); 159 | expect(cb11).toHaveBeenCalled(); 160 | expect(cb12).toHaveBeenCalled(); 161 | expect(cb13).toHaveBeenCalled(); 162 | expect(cb14).toHaveBeenCalled(); 163 | expect(cb15).toHaveBeenCalled(); 164 | expect(cb16).toHaveBeenCalled(); 165 | expect(cb17).toHaveBeenCalled(); 166 | expect(cb18).toHaveBeenCalled(); 167 | expect(cb19).toHaveBeenCalled(); 168 | expect(cb20).toHaveBeenCalled(); 169 | expect(cb21).toHaveBeenCalled(); 170 | expect(cb22).toHaveBeenCalled(); 171 | }); 172 | 173 | test('events', () => { 174 | const args = {}; 175 | jest.spyOn(controller, 'sendMessage').mockImplementation(() => {}); 176 | jest.spyOn(controller, 'sendAttachment').mockImplementation(() => {}); 177 | jest.spyOn(controller, 'downloadAttachment').mockImplementation(() => {}); 178 | jest.spyOn(controller, 'connect').mockImplementation(() => {}); 179 | jest.spyOn(controller, 'sendEvent').mockImplementation(() => {}); 180 | jest.spyOn(controller, 'getTranscript').mockImplementation(() => {}); 181 | jest.spyOn(controller, 'getChatDetails').mockImplementation(() => {}); 182 | jest.spyOn(controller, 'cancelParticipantAuthentication').mockImplementation(() => {}); 183 | 184 | session.sendMessage(args); 185 | expect(controller.sendMessage).toHaveBeenCalled(); 186 | session.sendAttachment(args); 187 | expect(controller.sendAttachment).toHaveBeenCalled(); 188 | session.downloadAttachment(args); 189 | expect(controller.downloadAttachment).toHaveBeenCalled(); 190 | session.connect(args); 191 | expect(controller.connect).toHaveBeenCalled(); 192 | session.sendEvent(args); 193 | expect(controller.sendEvent).toHaveBeenCalled(); 194 | session.getTranscript(args); 195 | expect(controller.getTranscript).toHaveBeenCalled(); 196 | session.getChatDetails(args); 197 | expect(controller.getChatDetails).toHaveBeenCalled(); 198 | session.cancelParticipantAuthentication(args); 199 | expect(controller.cancelParticipantAuthentication).toHaveBeenCalled(); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /src/core/connectionHelpers/LpcConnectionHelper.js: -------------------------------------------------------------------------------- 1 | import { EventBus } from "../eventbus"; 2 | import { LogManager } from "../../log"; 3 | import { 4 | ConnectionHelperEvents, 5 | ConnectionHelperStatus 6 | } from "./baseConnectionHelper"; 7 | import BaseConnectionHelper from "./baseConnectionHelper"; 8 | import WebSocketManager from "../../lib/amazon-connect-websocket-manager"; 9 | import { CHAT_EVENTS, CSM_CATEGORY, TRANSPORT_LIFETIME_IN_SECONDS, WEBSOCKET_EVENTS } from "../../constants"; 10 | import { csmService } from "../../service/csmService"; 11 | 12 | class LpcConnectionHelper extends BaseConnectionHelper { 13 | 14 | constructor(contactId, initialContactId, connectionDetailsProvider, websocketManager, logMetaData, connectionDetails) { 15 | super(connectionDetailsProvider, logMetaData); 16 | 17 | // WebsocketManager instance is only provided iff agent connections 18 | this.customerConnection = !websocketManager; 19 | 20 | if (this.customerConnection) { 21 | // ensure customer base instance exists for this contact ID 22 | if (!LpcConnectionHelper.customerBaseInstances[contactId]) { 23 | LpcConnectionHelper.customerBaseInstances[contactId] = 24 | new LpcConnectionHelperBase(connectionDetailsProvider, undefined, logMetaData, connectionDetails); 25 | } 26 | this.baseInstance = LpcConnectionHelper.customerBaseInstances[contactId]; 27 | } else { 28 | // cleanup agent base instance if it exists for old websocket manager 29 | if (LpcConnectionHelper.agentBaseInstance) { 30 | if (LpcConnectionHelper.agentBaseInstance.getWebsocketManager() !== websocketManager) { 31 | LpcConnectionHelper.agentBaseInstance.end(); 32 | LpcConnectionHelper.agentBaseInstance = null; 33 | } 34 | } 35 | // ensure agent base instance exists 36 | if (!LpcConnectionHelper.agentBaseInstance) { 37 | LpcConnectionHelper.agentBaseInstance = 38 | new LpcConnectionHelperBase(undefined, websocketManager, logMetaData); 39 | } 40 | this.baseInstance = LpcConnectionHelper.agentBaseInstance; 41 | } 42 | 43 | this.contactId = contactId; 44 | this.initialContactId = initialContactId; 45 | this.status = null; 46 | this.eventBus = new EventBus(); 47 | this.subscriptions = [ 48 | this.baseInstance.onEnded(this.handleEnded.bind(this)), 49 | this.baseInstance.onConnectionGain(this.handleConnectionGain.bind(this)), 50 | this.baseInstance.onConnectionLost(this.handleConnectionLost.bind(this)), 51 | this.baseInstance.onMessage(this.handleMessage.bind(this)), 52 | this.baseInstance.onDeepHeartbeatSuccess(this.handleDeepHeartbeatSuccess.bind(this)), 53 | this.baseInstance.onDeepHeartbeatFailure(this.handleDeepHeartbeatFailure.bind(this)), 54 | this.baseInstance.onBackgroundChatEnded(this.handleBackgroundChatEnded.bind(this)) 55 | ]; 56 | } 57 | 58 | start() { 59 | super.start(); 60 | return this.baseInstance.start(); 61 | } 62 | 63 | end() { 64 | super.end(); 65 | this.eventBus.unsubscribeAll(); 66 | this.subscriptions.forEach(unsubscribe => unsubscribe()); 67 | this.status = ConnectionHelperStatus.Ended; 68 | this.tryCleanup(); 69 | } 70 | 71 | tryCleanup() { 72 | if (this.customerConnection && !this.baseInstance.hasMessageSubscribers()) { 73 | this.baseInstance.end(); 74 | delete LpcConnectionHelper.customerBaseInstances[this.contactId]; 75 | } 76 | } 77 | 78 | getStatus() { 79 | return this.status || this.baseInstance.getStatus(); 80 | } 81 | 82 | onEnded(handler) { 83 | return this.eventBus.subscribe(ConnectionHelperEvents.Ended, handler); 84 | } 85 | 86 | handleEnded() { 87 | this.eventBus.trigger(ConnectionHelperEvents.Ended, {}); 88 | } 89 | 90 | onConnectionGain(handler) { 91 | return this.eventBus.subscribe(ConnectionHelperEvents.ConnectionGained, handler); 92 | } 93 | 94 | handleConnectionGain() { 95 | this.eventBus.trigger(ConnectionHelperEvents.ConnectionGained, {}); 96 | } 97 | 98 | onConnectionLost(handler) { 99 | return this.eventBus.subscribe(ConnectionHelperEvents.ConnectionLost, handler); 100 | } 101 | 102 | handleConnectionLost() { 103 | this.eventBus.trigger(ConnectionHelperEvents.ConnectionLost, {}); 104 | } 105 | 106 | onDeepHeartbeatSuccess(handler) { 107 | return this.eventBus.subscribe(ConnectionHelperEvents.DeepHeartbeatSuccess, handler); 108 | } 109 | 110 | handleDeepHeartbeatSuccess() { 111 | this.eventBus.trigger(ConnectionHelperEvents.DeepHeartbeatSuccess, {}); 112 | } 113 | 114 | onDeepHeartbeatFailure(handler) { 115 | return this.eventBus.subscribe(ConnectionHelperEvents.DeepHeartbeatFailure, handler); 116 | } 117 | 118 | handleDeepHeartbeatFailure() { 119 | this.eventBus.trigger(ConnectionHelperEvents.DeepHeartbeatFailure, {}); 120 | } 121 | 122 | onMessage(handler) { 123 | return this.eventBus.subscribe(ConnectionHelperEvents.IncomingMessage, handler); 124 | } 125 | 126 | onBackgroundChatEnded(handler) { 127 | return this.eventBus.subscribe(ConnectionHelperEvents.BackgroundChatEnded, handler); 128 | } 129 | 130 | handleBackgroundChatEnded() { 131 | this.eventBus.trigger(ConnectionHelperEvents.BackgroundChatEnded, {}); 132 | } 133 | 134 | handleMessage(message) { 135 | if (message.InitialContactId === this.initialContactId || message.ContactId === this.contactId || message.Type === CHAT_EVENTS.MESSAGE_METADATA) { 136 | this.eventBus.trigger(ConnectionHelperEvents.IncomingMessage, message); 137 | } 138 | } 139 | } 140 | LpcConnectionHelper.customerBaseInstances = {}; 141 | LpcConnectionHelper.agentBaseInstance = null; 142 | 143 | 144 | class LpcConnectionHelperBase { 145 | constructor(connectionDetailsProvider, websocketManager, logMetaData, connectionDetails) { 146 | this.status = ConnectionHelperStatus.NeverStarted; 147 | this.eventBus = new EventBus(); 148 | this.logger = LogManager.getLogger({ 149 | prefix: "ChatJS-LPCConnectionHelperBase", 150 | logMetaData 151 | }); 152 | this.initialConnectionDetails = connectionDetails; 153 | this.initWebsocketManager(websocketManager, connectionDetailsProvider, logMetaData); 154 | } 155 | 156 | initWebsocketManager(websocketManager, connectionDetailsProvider, logMetaData) { 157 | this.websocketManager = websocketManager || WebSocketManager.create(logMetaData); 158 | this.websocketManager.subscribeTopics(["aws/chat"]); 159 | this.subscriptions = [ 160 | this.websocketManager.onMessage("aws/chat", this.handleMessage.bind(this)), 161 | this.websocketManager.onConnectionGain(this.handleConnectionGain.bind(this)), 162 | this.websocketManager.onConnectionLost(this.handleConnectionLost.bind(this)), 163 | this.websocketManager.onInitFailure(this.handleEnded.bind(this)), 164 | this.websocketManager.onDeepHeartbeatSuccess?.(this.handleDeepHeartbeatSuccess.bind(this)), 165 | this.websocketManager.onDeepHeartbeatFailure?.(this.handleDeepHeartbeatFailure.bind(this)) 166 | ]; 167 | this.logger.info("Initializing websocket manager."); 168 | if (!websocketManager) { 169 | const startTime = new Date().getTime(); 170 | this.websocketManager.init(() => 171 | this._getConnectionDetails(connectionDetailsProvider, this.initialConnectionDetails, startTime).then((response) => { 172 | this.initialConnectionDetails = null; 173 | return response; 174 | })); 175 | } 176 | } 177 | 178 | _getConnectionDetails(connectionDetailsProvider, connectionDetails, startTime) { 179 | if (connectionDetails !== null && typeof connectionDetails === "object" && connectionDetails.expiry && connectionDetails.connectionTokenExpiry) { 180 | const logContent = {expiry: connectionDetails.expiry, transportLifeTimeInSeconds: TRANSPORT_LIFETIME_IN_SECONDS}; 181 | this.logger.debug("Websocket manager initialized. Connection details:", logContent); 182 | return Promise.resolve({ 183 | webSocketTransport: { 184 | url: connectionDetails.url, 185 | expiry: connectionDetails.expiry, 186 | transportLifeTimeInSeconds: TRANSPORT_LIFETIME_IN_SECONDS 187 | } 188 | }); 189 | } else { 190 | return connectionDetailsProvider.fetchConnectionDetails() 191 | .then(connectionDetails => { 192 | const details = { 193 | webSocketTransport: { 194 | url: connectionDetails.url, 195 | expiry: connectionDetails.expiry, 196 | transportLifeTimeInSeconds: TRANSPORT_LIFETIME_IN_SECONDS 197 | } 198 | }; 199 | const logContent = {expiry: connectionDetails.expiry, transportLifeTimeInSeconds: TRANSPORT_LIFETIME_IN_SECONDS}; 200 | this.logger.debug("Websocket manager initialized. Connection details:", logContent); 201 | this._addWebsocketInitCSMMetric(startTime); 202 | return details; 203 | } 204 | ).catch(error => { 205 | this.logger.error("Initializing Websocket Manager failed:", error); 206 | 207 | // are assuming that the chat has ended while the WebSocket connection was broken. 208 | if (this.status === ConnectionHelperStatus.ConnectionLost && error?._debug?.statusCode === 403) { 209 | this.handleBackgroundChatEnded(); 210 | } 211 | this._addWebsocketInitCSMMetric(startTime, true); 212 | throw error; 213 | }); 214 | } 215 | } 216 | 217 | _addWebsocketInitCSMMetric(startTime, isError = false) { 218 | csmService.addLatencyMetric(WEBSOCKET_EVENTS.InitWebsocket, startTime, CSM_CATEGORY.API); 219 | csmService.addCountAndErrorMetric(WEBSOCKET_EVENTS.InitWebsocket, CSM_CATEGORY.API, isError); 220 | } 221 | 222 | end() { 223 | // WebSocketProvider instance from streams does not have closeWebSocket 224 | if (this.websocketManager.closeWebSocket) { 225 | this.websocketManager.closeWebSocket(); 226 | } 227 | this.eventBus.unsubscribeAll(); 228 | this.subscriptions.forEach(unsubscribe => unsubscribe()); 229 | this.logger.info("Websocket closed. All event subscriptions are cleared."); 230 | } 231 | 232 | start() { 233 | if (this.status === ConnectionHelperStatus.NeverStarted) { 234 | this.status = ConnectionHelperStatus.Starting; 235 | } 236 | return Promise.resolve({ 237 | websocketStatus: this.status 238 | }); 239 | } 240 | 241 | onEnded(handler) { 242 | return this.eventBus.subscribe(ConnectionHelperEvents.Ended, handler); 243 | } 244 | 245 | handleEnded() { 246 | this.status = ConnectionHelperStatus.Ended; 247 | this.eventBus.trigger(ConnectionHelperEvents.Ended, {}); 248 | csmService.addCountMetric(WEBSOCKET_EVENTS.Ended, CSM_CATEGORY.API); 249 | this.logger.info("Websocket connection ended."); 250 | } 251 | 252 | onConnectionGain(handler) { 253 | return this.eventBus.subscribe(ConnectionHelperEvents.ConnectionGained, handler); 254 | } 255 | 256 | handleConnectionGain() { 257 | this.status = ConnectionHelperStatus.Connected; 258 | this.eventBus.trigger(ConnectionHelperEvents.ConnectionGained, {}); 259 | csmService.addCountMetric(WEBSOCKET_EVENTS.ConnectionGained, CSM_CATEGORY.API); 260 | this.logger.info("Websocket connection gained."); 261 | } 262 | 263 | onConnectionLost(handler) { 264 | return this.eventBus.subscribe(ConnectionHelperEvents.ConnectionLost, handler); 265 | } 266 | 267 | handleConnectionLost() { 268 | this.status = ConnectionHelperStatus.ConnectionLost; 269 | this.eventBus.trigger(ConnectionHelperEvents.ConnectionLost, {}); 270 | csmService.addCountMetric(WEBSOCKET_EVENTS.ConnectionLost, CSM_CATEGORY.API); 271 | this.logger.info("Websocket connection lost."); 272 | } 273 | 274 | onMessage(handler) { 275 | return this.eventBus.subscribe(ConnectionHelperEvents.IncomingMessage, handler); 276 | } 277 | 278 | handleMessage(message) { 279 | let parsedMessage; 280 | try { 281 | parsedMessage = JSON.parse(message.content); 282 | this.eventBus.trigger(ConnectionHelperEvents.IncomingMessage, parsedMessage); 283 | csmService.addCountMetric(WEBSOCKET_EVENTS.IncomingMessage, CSM_CATEGORY.API); 284 | this.logger.info("this.eventBus trigger Websocket incoming message", ConnectionHelperEvents.IncomingMessage, parsedMessage); 285 | } catch (e) { 286 | this._sendInternalLogToServer(this.logger.error("Wrong message format")); 287 | } 288 | } 289 | 290 | onBackgroundChatEnded(handler) { 291 | return this.eventBus.subscribe(ConnectionHelperEvents.BackgroundChatEnded, handler); 292 | } 293 | 294 | handleBackgroundChatEnded() { 295 | this.eventBus.trigger(ConnectionHelperEvents.BackgroundChatEnded); 296 | } 297 | 298 | getStatus() { 299 | return this.status; 300 | } 301 | 302 | getWebsocketManager() { 303 | return this.websocketManager; 304 | } 305 | 306 | hasMessageSubscribers() { 307 | return this.eventBus.getSubscriptions(ConnectionHelperEvents.IncomingMessage).length > 0; 308 | } 309 | 310 | _sendInternalLogToServer(logEntry) { 311 | if (logEntry && typeof logEntry.sendInternalLogToServer === "function") 312 | logEntry.sendInternalLogToServer(); 313 | 314 | return logEntry; 315 | } 316 | 317 | onDeepHeartbeatSuccess(handler) { 318 | return this.eventBus.subscribe(ConnectionHelperEvents.DeepHeartbeatSuccess, handler); 319 | } 320 | 321 | handleDeepHeartbeatSuccess() { 322 | this.status = ConnectionHelperStatus.DeepHeartbeatSuccess; 323 | this.eventBus.trigger(ConnectionHelperEvents.DeepHeartbeatSuccess, {}); 324 | csmService.addCountMetric(WEBSOCKET_EVENTS.DeepHeartbeatSuccess, CSM_CATEGORY.API); 325 | this.logger.info("Websocket deep heartbeat success."); 326 | } 327 | 328 | onDeepHeartbeatFailure(handler) { 329 | return this.eventBus.subscribe(ConnectionHelperEvents.DeepHeartbeatFailure, handler); 330 | } 331 | 332 | handleDeepHeartbeatFailure() { 333 | this.status = ConnectionHelperStatus.DeepHeartbeatFailure; 334 | this.eventBus.trigger(ConnectionHelperEvents.DeepHeartbeatFailure, {}); 335 | csmService.addCountMetric(WEBSOCKET_EVENTS.DeepHeartbeatFailure, CSM_CATEGORY.API); 336 | this.logger.info("Websocket deep heartbeat failure."); 337 | } 338 | } 339 | 340 | export default LpcConnectionHelper; 341 | -------------------------------------------------------------------------------- /src/core/connectionHelpers/baseConnectionHelper.js: -------------------------------------------------------------------------------- 1 | import { CONNECTION_TOKEN_POLLING_INTERVAL_IN_MS, CONNECTION_TOKEN_EXPIRY_BUFFER_IN_MS } from "../../constants"; 2 | import { LogManager } from "../../log"; 3 | const ConnectionHelperStatus = { 4 | NeverStarted: "NeverStarted", 5 | Starting: "Starting", 6 | Connected: "Connected", 7 | ConnectionLost: "ConnectionLost", 8 | Ended: "Ended", 9 | DeepHeartbeatSuccess: "DeepHeartbeatSuccess", 10 | DeepHeartbeatFailure: "DeepHeartbeatFailure" 11 | }; 12 | 13 | const ConnectionHelperEvents = { 14 | ConnectionLost: "ConnectionLost", // event data is: {reason: ...} 15 | ConnectionGained: "ConnectionGained", // event data is: {reason: ...} 16 | Ended: "Ended", // event data is: {reason: ...} 17 | IncomingMessage: "IncomingMessage", // event data is: {payloadString: ...} 18 | DeepHeartbeatSuccess: "DeepHeartbeatSuccess", 19 | DeepHeartbeatFailure: "DeepHeartbeatFailure", 20 | BackgroundChatEnded: "BackgroundChatEnded" 21 | }; 22 | 23 | const ConnectionInfoType = { 24 | WEBSOCKET: "WEBSOCKET", 25 | CONNECTION_CREDENTIALS: "CONNECTION_CREDENTIALS" 26 | }; 27 | 28 | export default class BaseConnectionHelper { 29 | constructor(connectionDetailsProvider, logMetaData) { 30 | this.connectionDetailsProvider = connectionDetailsProvider; 31 | this.isStarted = false; 32 | this.logger = LogManager.getLogger({ prefix: "ChatJS-BaseConnectionHelper", logMetaData }); 33 | } 34 | 35 | startConnectionTokenPolling(isFirstCall=false, expiry=CONNECTION_TOKEN_POLLING_INTERVAL_IN_MS) { 36 | if (!isFirstCall){ 37 | //TODO: use Type field to avoid fetching websocket connection 38 | return this.connectionDetailsProvider.fetchConnectionDetails() 39 | .then(response => { 40 | this.logger.info("Connection token polling succeeded."); 41 | expiry = this.getTimeToConnectionTokenExpiry(); 42 | this.timeout = setTimeout(this.startConnectionTokenPolling.bind(this), expiry); 43 | return response; 44 | }) 45 | .catch((e) => { 46 | this.logger.error("An error occurred when attempting to fetch the connection token during Connection Token Polling", e); 47 | this.timeout = setTimeout(this.startConnectionTokenPolling.bind(this), expiry); 48 | return e; 49 | }); 50 | } 51 | else { 52 | this.logger.info("First time polling connection token."); 53 | this.timeout = setTimeout(this.startConnectionTokenPolling.bind(this), expiry); 54 | } 55 | } 56 | 57 | start() { 58 | if (this.isStarted) { 59 | return this.getConnectionToken(); 60 | } 61 | this.isStarted = true; 62 | return this.startConnectionTokenPolling( 63 | true, 64 | this.getTimeToConnectionTokenExpiry() 65 | ); 66 | } 67 | 68 | end() { 69 | clearTimeout(this.timeout); 70 | } 71 | 72 | getConnectionToken() { 73 | return this.connectionDetailsProvider.getFetchedConnectionToken(); 74 | } 75 | 76 | getConnectionTokenExpiry() { 77 | return this.connectionDetailsProvider.getConnectionTokenExpiry(); 78 | } 79 | 80 | getTimeToConnectionTokenExpiry() { 81 | var dateExpiry = new Date( 82 | this.getConnectionTokenExpiry() 83 | ).getTime(); 84 | var now = new Date().getTime(); 85 | return dateExpiry - now - CONNECTION_TOKEN_EXPIRY_BUFFER_IN_MS; 86 | } 87 | } 88 | 89 | export { 90 | ConnectionHelperStatus, 91 | ConnectionHelperEvents, 92 | ConnectionInfoType 93 | }; 94 | -------------------------------------------------------------------------------- /src/core/connectionHelpers/baseConnectionHelper.spec.js: -------------------------------------------------------------------------------- 1 | import BaseConnectionHelper from "./baseConnectionHelper"; 2 | 3 | describe("BaseConnectionHelper", () => { 4 | 5 | let baseConnectionHelper; 6 | const connectionDetailsProvider = { 7 | fetchConnectionDetails: () => {}, 8 | getConnectionTokenExpiry: () => {} 9 | }; 10 | 11 | beforeEach(() => { 12 | connectionDetailsProvider.fetchConnectionDetails = jest.fn(() => Promise.resolve({ 13 | url: "url", 14 | expiry: "expiry", 15 | transportLifeTimeInSeconds: new Date(new Date().getTime() + 60*60*1000), 16 | connectionAcknowledged: "connectionAcknowledged", 17 | connectionToken: "connectionToken", 18 | connectionTokenExpiry: new Date(new Date().getTime() + 22*60*60*1000), 19 | })); 20 | // .getConnectionTokenExpiry usually returns the date, in ms since 1969, when this connection token expires) 21 | connectionDetailsProvider.getConnectionTokenExpiry = jest.fn(() => { return new Date(new Date().getTime() + 22*60*60*1000);}); 22 | baseConnectionHelper = new BaseConnectionHelper(connectionDetailsProvider); 23 | jest.useFakeTimers(); 24 | }); 25 | 26 | afterEach(() => { 27 | jest.clearAllTimers(); 28 | }); 29 | 30 | afterAll(() => { 31 | jest.useRealTimers(); 32 | }); 33 | 34 | test("start initiates fetch interval", () => { 35 | baseConnectionHelper.start(); 36 | expect(connectionDetailsProvider.fetchConnectionDetails).toHaveBeenCalledTimes(0); 37 | jest.runOnlyPendingTimers(); 38 | expect(connectionDetailsProvider.fetchConnectionDetails).toHaveBeenCalledTimes(1); 39 | }); 40 | 41 | test("end stops fetch interval", () => { 42 | baseConnectionHelper.start(); 43 | jest.runOnlyPendingTimers(); 44 | baseConnectionHelper.end(); 45 | jest.runOnlyPendingTimers(); 46 | expect(connectionDetailsProvider.fetchConnectionDetails).toHaveBeenCalledTimes(1); 47 | }); 48 | 49 | test("getTimeToConnectionTokenExpiry returns the expiry, not the date", () => { 50 | // expect that the expiry returned is a length in ms between now and the expiry date, not a date itself (in ms). 51 | // A date (in ms) would be larger than this constant. 52 | expect(baseConnectionHelper.getTimeToConnectionTokenExpiry()).toBeLessThan(100000000); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/core/connectionHelpers/connectionDetailsProvider.js: -------------------------------------------------------------------------------- 1 | import { IllegalArgumentException } from "../exceptions"; 2 | import { ConnectionInfoType } from "./baseConnectionHelper"; 3 | import { ACPS_METHODS, CSM_CATEGORY, SESSION_TYPES, TRANSPORT_LIFETIME_IN_SECONDS, CONN_ACK_FAILED } from "../../constants"; 4 | import { csmService } from "../../service/csmService"; 5 | 6 | export default class ConnectionDetailsProvider { 7 | 8 | constructor(participantToken, chatClient, sessionType, getConnectionToken=null) { 9 | this.chatClient = chatClient; 10 | this.participantToken = participantToken || null; 11 | this.connectionDetails = null; 12 | this.connectionToken = null; 13 | this.connectionTokenExpiry = null; 14 | this.sessionType = sessionType; 15 | this.getConnectionToken = getConnectionToken; 16 | } 17 | 18 | getFetchedConnectionToken() { 19 | return this.connectionToken; 20 | } 21 | 22 | getConnectionTokenExpiry() { 23 | return this.connectionTokenExpiry; 24 | } 25 | 26 | getConnectionDetails() { 27 | return this.connectionDetails; 28 | } 29 | 30 | fetchConnectionDetails() { 31 | return this._fetchConnectionDetails().then((connectionDetails) => connectionDetails); 32 | } 33 | 34 | _handleCreateParticipantConnectionResponse(connectionDetails, ConnectParticipant) { 35 | this.connectionDetails = { 36 | url: connectionDetails.Websocket.Url, 37 | expiry: connectionDetails.Websocket.ConnectionExpiry, 38 | transportLifeTimeInSeconds: TRANSPORT_LIFETIME_IN_SECONDS, 39 | connectionAcknowledged: ConnectParticipant, 40 | connectionToken: connectionDetails.ConnectionCredentials.ConnectionToken, 41 | connectionTokenExpiry: connectionDetails.ConnectionCredentials.Expiry, 42 | }; 43 | this.connectionToken = connectionDetails.ConnectionCredentials.ConnectionToken; 44 | this.connectionTokenExpiry = connectionDetails.ConnectionCredentials.Expiry; 45 | return this.connectionDetails; 46 | } 47 | 48 | _handleGetConnectionTokenResponse(connectionTokenDetails) { 49 | this.connectionDetails = { 50 | url: null, 51 | expiry: null, 52 | connectionToken: connectionTokenDetails.participantToken, 53 | connectionTokenExpiry: connectionTokenDetails.expiry, 54 | transportLifeTimeInSeconds: TRANSPORT_LIFETIME_IN_SECONDS, 55 | connectionAcknowledged: false, 56 | }; 57 | this.connectionToken = connectionTokenDetails.participantToken; 58 | this.connectionTokenExpiry = connectionTokenDetails.expiry; 59 | return Promise.resolve(this.connectionDetails); 60 | } 61 | 62 | callCreateParticipantConnection({ Type = true, ConnectParticipant = false } = {}){ 63 | const startTime = new Date().getTime(); 64 | return this.chatClient 65 | .createParticipantConnection(this.participantToken, Type ? [ConnectionInfoType.WEBSOCKET, ConnectionInfoType.CONNECTION_CREDENTIALS] : null, ConnectParticipant ? ConnectParticipant : null) 66 | .then((response) => { 67 | if (Type) { 68 | this._addParticipantConnectionMetric(startTime); 69 | return this._handleCreateParticipantConnectionResponse(response.data, ConnectParticipant); 70 | } 71 | }) 72 | .catch( error => { 73 | if (Type) { 74 | this._addParticipantConnectionMetric(startTime, true); 75 | } 76 | return Promise.reject({ 77 | reason: "Failed to fetch connectionDetails with createParticipantConnection", 78 | _debug: error 79 | }); 80 | }); 81 | } 82 | 83 | _addParticipantConnectionMetric(startTime, error = false) { 84 | csmService.addLatencyMetricWithStartTime(ACPS_METHODS.CREATE_PARTICIPANT_CONNECTION, startTime, CSM_CATEGORY.API); 85 | csmService.addCountAndErrorMetric(ACPS_METHODS.CREATE_PARTICIPANT_CONNECTION, CSM_CATEGORY.API, error); 86 | } 87 | 88 | async _fetchConnectionDetails() { 89 | // If this is a customer session, use the provided participantToken to call createParticipantConnection for our connection details. 90 | if (this.sessionType === SESSION_TYPES.CUSTOMER) { 91 | return this.callCreateParticipantConnection(); 92 | } 93 | // If this is an agent session, we can't assume that the participantToken is valid. 94 | // In this case, we use the getConnectionToken API to fetch a valid connectionToken and expiry. 95 | // If that fails, for now we try with createParticipantConnection. 96 | else if (this.sessionType === SESSION_TYPES.AGENT){ 97 | return this.getConnectionToken() 98 | .then((response) => { 99 | return this._handleGetConnectionTokenResponse(response.chatTokenTransport); 100 | }) 101 | .catch(() => { 102 | return this.callCreateParticipantConnection({ 103 | Type: true, 104 | ConnectParticipant: true 105 | }).catch((err) => { 106 | throw new Error({ 107 | type: CONN_ACK_FAILED, 108 | errorMessage: err 109 | }); 110 | }); 111 | }); 112 | } 113 | else { 114 | return Promise.reject({ 115 | reason: "Failed to fetch connectionDetails.", 116 | _debug: new IllegalArgumentException("Failed to fetch connectionDetails.") 117 | }); 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/core/connectionHelpers/connectionDetailsProvider.spec.js: -------------------------------------------------------------------------------- 1 | import ConnectionDetailsProvider from "./connectionDetailsProvider"; 2 | import { ConnectionInfoType } from "./baseConnectionHelper"; 3 | import {ACPS_METHODS, CSM_CATEGORY, SESSION_TYPES } from "../../constants"; 4 | import { csmService } from "../../service/csmService"; 5 | 6 | jest.useFakeTimers(); 7 | describe("ConnectionDetailsProvider", () => { 8 | 9 | const chatClient = { 10 | }; 11 | 12 | let connectionDetailsProvider; 13 | let participantToken; 14 | let fetchedConnectionDetails; 15 | let getConnectionToken; 16 | 17 | function setupCustomer() { 18 | connectionDetailsProvider = new ConnectionDetailsProvider(participantToken, chatClient, SESSION_TYPES.CUSTOMER); 19 | } 20 | function setupAgent() { 21 | connectionDetailsProvider = new ConnectionDetailsProvider(null, chatClient, SESSION_TYPES.AGENT, getConnectionToken); 22 | } 23 | beforeEach(() => { 24 | jest.resetAllMocks(); 25 | jest.spyOn(csmService, 'addLatencyMetricWithStartTime').mockImplementation(() => {}); 26 | jest.spyOn(csmService, 'addCountAndErrorMetric').mockImplementation(() => {}); 27 | 28 | fetchedConnectionDetails = { 29 | ParticipantCredentials: { 30 | ConnectionAuthenticationToken: 'token', 31 | Expiry: 0 32 | }, 33 | url: 'url', 34 | expiry: 'expiry' 35 | }; 36 | 37 | participantToken = 'ptoken'; 38 | 39 | getConnectionToken = jest.fn((function () { 40 | let counter = 0; 41 | return () => { 42 | counter +=1; 43 | return Promise.resolve({ 44 | chatTokenTransport: { 45 | participantToken: fetchedConnectionDetails.ParticipantCredentials.ConnectionAuthenticationToken + counter, 46 | expiry: 0 + counter 47 | } 48 | }); 49 | }; 50 | })()); 51 | 52 | chatClient.createParticipantConnection = jest.fn((function () { 53 | let counter = 0; 54 | return () => { 55 | counter +=1; 56 | return Promise.resolve({ 57 | data: { 58 | ConnectionCredentials: { 59 | ConnectionToken: fetchedConnectionDetails.ParticipantCredentials.ConnectionAuthenticationToken + counter, 60 | Expiry: 0 + counter 61 | }, 62 | Websocket: { 63 | Url: fetchedConnectionDetails.url + counter, 64 | ConnectionExpiry: fetchedConnectionDetails.expiry + counter 65 | } 66 | } 67 | }); 68 | }; 69 | } )()); 70 | }); 71 | 72 | 73 | describe("Customer Session", () => { 74 | describe(".fetchConnectionDetails()", () => { 75 | test("returns valid url on first call", async () => { 76 | setupCustomer(); 77 | const connectionDetails = await connectionDetailsProvider.fetchConnectionDetails(); 78 | expect(connectionDetails.url).toEqual("url1"); 79 | expect(connectionDetails.expiry).toEqual("expiry1"); 80 | expect(connectionDetails.connectionToken).toBe('token1'); 81 | expect(connectionDetails.connectionTokenExpiry).toBe(1); 82 | expect(connectionDetails.connectionAcknowledged).toBe(false); 83 | }); 84 | 85 | test("returns valid url on second call", async () => { 86 | setupCustomer(); 87 | await connectionDetailsProvider.fetchConnectionDetails(); 88 | const connectionDetails = await connectionDetailsProvider.fetchConnectionDetails(); 89 | expect(connectionDetails.url).toEqual("url2"); 90 | expect(connectionDetails.expiry).toEqual("expiry2"); 91 | expect(connectionDetails.connectionToken).toBe('token2'); 92 | expect(connectionDetails.connectionTokenExpiry).toBe(2); 93 | expect(connectionDetails.connectionAcknowledged).toBe(false); 94 | }); 95 | 96 | test("has correct inner state after first call", async () => { 97 | setupCustomer(); 98 | await connectionDetailsProvider.fetchConnectionDetails(); 99 | expect(connectionDetailsProvider.connectionDetails.url).toEqual("url1"); 100 | expect(connectionDetailsProvider.connectionDetails.expiry).toEqual("expiry1"); 101 | expect(connectionDetailsProvider.connectionDetails.connectionToken).toEqual("token1"); 102 | expect(connectionDetailsProvider.connectionDetails.connectionTokenExpiry).toEqual(1); 103 | expect(connectionDetailsProvider.connectionToken).toEqual("token1"); 104 | expect(connectionDetailsProvider.connectionTokenExpiry).toEqual(1); 105 | }); 106 | 107 | test("updates internal state on second call", async () => { 108 | setupCustomer(); 109 | await connectionDetailsProvider.fetchConnectionDetails(); 110 | await connectionDetailsProvider.fetchConnectionDetails(); 111 | expect(connectionDetailsProvider.connectionDetails.url).toEqual("url2"); 112 | expect(connectionDetailsProvider.connectionDetails.expiry).toEqual("expiry2"); 113 | expect(connectionDetailsProvider.connectionDetails.connectionToken).toEqual("token2"); 114 | expect(connectionDetailsProvider.connectionDetails.connectionTokenExpiry).toEqual(2); 115 | expect(connectionDetailsProvider.connectionToken).toEqual("token2"); 116 | expect(connectionDetailsProvider.connectionTokenExpiry).toEqual(2); 117 | 118 | }); 119 | 120 | test("hits API on first call", async () => { 121 | setupCustomer(); 122 | await connectionDetailsProvider.fetchConnectionDetails(); 123 | expect(chatClient.createParticipantConnection).toHaveBeenCalledTimes(1); 124 | expect(chatClient.createParticipantConnection).toHaveBeenLastCalledWith(participantToken, [ConnectionInfoType.WEBSOCKET, ConnectionInfoType.CONNECTION_CREDENTIALS], null); 125 | expect(csmService.addCountAndErrorMetric).toHaveBeenCalledWith(ACPS_METHODS.CREATE_PARTICIPANT_CONNECTION, CSM_CATEGORY.API, false); 126 | expect(csmService.addLatencyMetricWithStartTime).toHaveBeenCalledWith(ACPS_METHODS.CREATE_PARTICIPANT_CONNECTION, expect.anything(), CSM_CATEGORY.API); 127 | }); 128 | 129 | test("hits API on second call", async () => { 130 | setupCustomer(); 131 | await connectionDetailsProvider.fetchConnectionDetails(); 132 | await connectionDetailsProvider.fetchConnectionDetails(); 133 | expect(chatClient.createParticipantConnection).toHaveBeenCalledTimes(2); 134 | expect(chatClient.createParticipantConnection).toHaveBeenLastCalledWith(participantToken, [ConnectionInfoType.WEBSOCKET, ConnectionInfoType.CONNECTION_CREDENTIALS], null); 135 | }); 136 | 137 | test("hits API on first call, and createParticipantConnection fails", async () => { 138 | chatClient.createParticipantConnection = jest.fn(() => Promise.reject({})); 139 | setupCustomer(); 140 | try { 141 | await connectionDetailsProvider.fetchConnectionDetails(); 142 | expect(false).toEqual(true); 143 | } catch (e) { 144 | expect(chatClient.createParticipantConnection).toHaveBeenCalledTimes(1); 145 | expect(chatClient.createParticipantConnection).toHaveBeenLastCalledWith(participantToken, [ConnectionInfoType.WEBSOCKET, ConnectionInfoType.CONNECTION_CREDENTIALS], null); 146 | expect(csmService.addCountAndErrorMetric).toHaveBeenCalledWith(ACPS_METHODS.CREATE_PARTICIPANT_CONNECTION, CSM_CATEGORY.API, true); 147 | expect(csmService.addLatencyMetricWithStartTime).toHaveBeenCalledWith(ACPS_METHODS.CREATE_PARTICIPANT_CONNECTION, expect.anything(), CSM_CATEGORY.API); 148 | } 149 | }); 150 | }); 151 | }); 152 | 153 | describe("Agent Session", () => { 154 | describe(".fetchConnectionDetails()", () => { 155 | test("returns valid url on first call", async () => { 156 | setupAgent(); 157 | const connectionDetails = await connectionDetailsProvider.fetchConnectionDetails(); 158 | expect(connectionDetails.url).toEqual(null); 159 | expect(connectionDetails.expiry).toEqual(null); 160 | expect(connectionDetails.connectionToken).toBe('token1'); 161 | }); 162 | 163 | test("returns valid url on second call", async () => { 164 | setupAgent(); 165 | await connectionDetailsProvider.fetchConnectionDetails(); 166 | const connectionDetails = await connectionDetailsProvider.fetchConnectionDetails(); 167 | expect(connectionDetails.url).toEqual(null); 168 | expect(connectionDetails.expiry).toEqual(null); 169 | expect(connectionDetails.connectionToken).toBe('token2'); 170 | }); 171 | 172 | test("has correct inner state after first call", async () => { 173 | setupAgent(); 174 | await connectionDetailsProvider.fetchConnectionDetails(); 175 | expect(connectionDetailsProvider.connectionDetails.url).toEqual(null); 176 | expect(connectionDetailsProvider.connectionDetails.expiry).toEqual(null); 177 | expect(connectionDetailsProvider.connectionDetails.connectionToken).toBe('token1'); 178 | expect(connectionDetailsProvider.connectionDetails.connectionTokenExpiry).toBe(1); 179 | expect(connectionDetailsProvider.connectionToken).toBe('token1'); 180 | expect(connectionDetailsProvider.connectionTokenExpiry).toBe(1); 181 | }); 182 | 183 | test("updates internal state on second call", async () => { 184 | setupAgent(); 185 | await connectionDetailsProvider.fetchConnectionDetails(); 186 | await connectionDetailsProvider.fetchConnectionDetails(); 187 | expect(connectionDetailsProvider.connectionDetails.url).toEqual(null); 188 | expect(connectionDetailsProvider.connectionDetails.expiry).toEqual(null); 189 | expect(connectionDetailsProvider.connectionDetails.connectionToken).toBe('token2'); 190 | expect(connectionDetailsProvider.connectionDetails.connectionTokenExpiry).toBe(2); 191 | expect(connectionDetailsProvider.connectionToken).toBe('token2'); 192 | expect(connectionDetailsProvider.connectionTokenExpiry).toBe(2); 193 | }); 194 | 195 | test("hits API on first call", async () => { 196 | setupAgent(); 197 | await connectionDetailsProvider.fetchConnectionDetails(); 198 | expect(getConnectionToken).toHaveBeenCalledTimes(1); 199 | }); 200 | 201 | test("hits API on second call", async () => { 202 | setupAgent(); 203 | await connectionDetailsProvider.fetchConnectionDetails(); 204 | await connectionDetailsProvider.fetchConnectionDetails(); 205 | expect(getConnectionToken).toHaveBeenCalledTimes(2); 206 | }); 207 | 208 | test("makes createParticipantAPI call if connectionToken is expired", async () => { 209 | getConnectionToken = jest.fn().mockReturnValue(Promise.reject("expired")); 210 | setupAgent(); 211 | const connectionDetails = await connectionDetailsProvider.fetchConnectionDetails(); 212 | expect(connectionDetails.url).toEqual("url1"); 213 | expect(connectionDetails.expiry).toEqual("expiry1"); 214 | }); 215 | }); 216 | }); 217 | }); 218 | -------------------------------------------------------------------------------- /src/core/eventbus.js: -------------------------------------------------------------------------------- 1 | import Utils from "../utils"; 2 | 3 | const ALL_EVENTS = "<>"; 4 | 5 | /** 6 | * An object representing an event subscription in an EventBus. 7 | */ 8 | var Subscription = function(subMap, eventName, f) { 9 | this.subMap = subMap; 10 | this.id = Utils.randomId(); 11 | this.eventName = eventName; 12 | this.f = f; 13 | }; 14 | 15 | /** 16 | * Unsubscribe the handler of this subscription from the EventBus 17 | * from which it was created. 18 | */ 19 | Subscription.prototype.unsubscribe = function() { 20 | this.subMap.unsubscribe(this.eventName, this.id); 21 | }; 22 | 23 | /** 24 | * A map of event subscriptions, used by the EventBus. 25 | */ 26 | var SubscriptionMap = function() { 27 | this.subIdMap = {}; 28 | this.subEventNameMap = {}; 29 | }; 30 | 31 | /** 32 | * Add a subscription for the named event. Creates a new Subscription 33 | * object and returns it. This object can be used to unsubscribe. 34 | */ 35 | SubscriptionMap.prototype.subscribe = function(eventName, f) { 36 | var sub = new Subscription(this, eventName, f); 37 | 38 | this.subIdMap[sub.id] = sub; 39 | var subList = this.subEventNameMap[eventName] || []; 40 | subList.push(sub); 41 | this.subEventNameMap[eventName] = subList; 42 | return () => sub.unsubscribe(); 43 | }; 44 | 45 | /** 46 | * Unsubscribe a subscription matching the given event name and id. 47 | */ 48 | SubscriptionMap.prototype.unsubscribe = function(eventName, subId) { 49 | if (Utils.contains(this.subEventNameMap, eventName)) { 50 | this.subEventNameMap[eventName] = this.subEventNameMap[eventName].filter( 51 | function(s) { 52 | return s.id !== subId; 53 | } 54 | ); 55 | 56 | if (this.subEventNameMap[eventName].length < 1) { 57 | delete this.subEventNameMap[eventName]; 58 | } 59 | } 60 | 61 | if (Utils.contains(this.subIdMap, subId)) { 62 | delete this.subIdMap[subId]; 63 | } 64 | }; 65 | 66 | /** 67 | * Get a list of all subscriptions in the subscription map. 68 | */ 69 | SubscriptionMap.prototype.getAllSubscriptions = function() { 70 | return Utils.values(this.subEventNameMap).reduce(function(a, b) { 71 | return a.concat(b); 72 | }, []); 73 | }; 74 | 75 | /** 76 | * Get a list of subscriptions for the given event name, or an empty 77 | * list if there are no subscriptions. 78 | */ 79 | SubscriptionMap.prototype.getSubscriptions = function(eventName) { 80 | return this.subEventNameMap[eventName] || []; 81 | }; 82 | 83 | /** 84 | * An object which maintains a map of subscriptions and serves as the 85 | * mechanism for triggering events to be handled by subscribers. 86 | * @type Class 87 | */ 88 | var EventBus = function(paramsIn) { 89 | var params = paramsIn || {}; 90 | 91 | this.subMap = new SubscriptionMap(); 92 | this.logEvents = params.logEvents || false; 93 | }; 94 | 95 | /** 96 | * Subscribe to the named event. Returns a new Subscription object 97 | * which can be used to unsubscribe. 98 | */ 99 | EventBus.prototype.subscribe = function(eventName, f) { 100 | Utils.assertNotNull(eventName, "eventName"); 101 | Utils.assertNotNull(f, "f"); 102 | Utils.assertTrue(Utils.isFunction(f), "f must be a function"); 103 | return this.subMap.subscribe(eventName, f); 104 | }; 105 | 106 | /** 107 | * Subscribe a function to be called on all events. 108 | */ 109 | EventBus.prototype.subscribeAll = function(f) { 110 | Utils.assertNotNull(f, "f"); 111 | Utils.assertTrue(Utils.isFunction(f), "f must be a function"); 112 | return this.subMap.subscribe(ALL_EVENTS, f); 113 | }; 114 | 115 | /** 116 | * Get a list of subscriptions for the given event name, or an empty 117 | * list if there are no subscriptions. 118 | */ 119 | EventBus.prototype.getSubscriptions = function(eventName) { 120 | return this.subMap.getSubscriptions(eventName); 121 | }; 122 | 123 | /** 124 | * Trigger the given event with the given data. All methods subscribed 125 | * to this event will be called and are provided with the given arbitrary 126 | * data object and the name of the event, in that order. 127 | */ 128 | EventBus.prototype.trigger = function(eventName, data) { 129 | Utils.assertNotNull(eventName, "eventName"); 130 | var self = this; 131 | var allEventSubs = this.subMap.getSubscriptions(ALL_EVENTS); 132 | var eventSubs = this.subMap.getSubscriptions(eventName); 133 | 134 | // if (this.logEvents && (eventName !== connect.EventType.LOG && eventName !== connect.EventType.MASTER_RESPONSE && eventName !== connect.EventType.API_METRIC)) { 135 | // connect.getLog().trace("Publishing event: %s", eventName); 136 | // } 137 | 138 | allEventSubs.concat(eventSubs).forEach(function(sub) { 139 | try { 140 | sub.f(data || null, eventName, self); 141 | } catch (e) { 142 | // connect 143 | // .getLog() 144 | // .error("'%s' event handler failed.", eventName) 145 | // .withException(e); 146 | } 147 | }); 148 | }; 149 | 150 | /** 151 | * Trigger the given event with the given data. All methods subscribed 152 | * to this event will be called and are provided with the given arbitrary 153 | * data object and the name of the event, in that order. 154 | */ 155 | EventBus.prototype.triggerAsync = function(eventName, data) { 156 | setTimeout(() => this.trigger(eventName, data), 0); 157 | }; 158 | 159 | /** 160 | * Returns a closure which bridges an event from another EventBus to this bus. 161 | * 162 | * Usage: 163 | * conduit.onUpstream("MyEvent", bus.bridge()); 164 | */ 165 | EventBus.prototype.bridge = function() { 166 | var self = this; 167 | return function(data, event) { 168 | self.trigger(event, data); 169 | }; 170 | }; 171 | 172 | /** 173 | * Unsubscribe all events in the event bus. 174 | */ 175 | EventBus.prototype.unsubscribeAll = function() { 176 | this.subMap.getAllSubscriptions().forEach(function(sub) { 177 | sub.unsubscribe(); 178 | }); 179 | }; 180 | 181 | export { EventBus }; 182 | -------------------------------------------------------------------------------- /src/core/exceptions.js: -------------------------------------------------------------------------------- 1 | class ValueError extends Error { 2 | constructor(message) { 3 | super(message); 4 | this.name = "ValueError"; 5 | } 6 | } 7 | 8 | class UnImplementedMethodException extends Error { 9 | constructor(message) { 10 | super(message); 11 | this.name = "UnImplementedMethod"; 12 | } 13 | } 14 | 15 | class IllegalArgumentException extends Error { 16 | constructor(message, argument) { 17 | super(message); 18 | this.name = "IllegalArgument"; 19 | this.argument = argument; 20 | } 21 | } 22 | 23 | class IllegalStateException extends Error { 24 | constructor(message) { 25 | super(message); 26 | this.name = "IllegalState"; 27 | } 28 | } 29 | 30 | class IllegalJsonException extends Error { 31 | constructor(message, args) { 32 | super(message); 33 | this.name = "IllegalState"; 34 | this.causeException = args.causeException; 35 | this.originalJsonString = args.originalJsonString; 36 | } 37 | } 38 | 39 | export { 40 | UnImplementedMethodException, 41 | IllegalArgumentException, 42 | IllegalStateException, 43 | IllegalJsonException, 44 | ValueError 45 | }; 46 | -------------------------------------------------------------------------------- /src/globalConfig.js: -------------------------------------------------------------------------------- 1 | import { FEATURES, DEFAULT_MESSAGE_RECEIPTS_THROTTLE_MS } from "./constants"; 2 | import { LogManager } from "./log"; 3 | 4 | class GlobalConfigImpl { 5 | constructor() { 6 | this.stage = "prod"; 7 | this.region = "us-west-2"; 8 | this.regionOverride = ""; //used for region failover 9 | this.cell = "1"; 10 | this.reconnect = true; 11 | let self = this; 12 | this.logger = LogManager.getLogger({ 13 | prefix: "ChatJS-GlobalConfig", 14 | }); 15 | this.features = new Proxy([], { 16 | set: (target, property, value) => { 17 | this.stage !== "test-stage2" && this.logger.info("new features added, initialValue: " 18 | + target[property] + " , newValue: " + value, Array.isArray(target[property])); 19 | let oldVal = target[property]; 20 | //fire change listeners 21 | if (Array.isArray(value)) { 22 | value.forEach(feature => { 23 | //if a new feature is added 24 | if (Array.isArray(oldVal) && oldVal.indexOf(feature) === -1 && 25 | Array.isArray(self.featureChangeListeners[feature])) { 26 | 27 | self.featureChangeListeners[feature].forEach(callback => callback()); 28 | self._cleanFeatureChangeListener(feature); 29 | } 30 | }); 31 | } 32 | //change the value in this.features object. 33 | target[property] = value; 34 | return true; 35 | } 36 | }); 37 | this.setFeatureFlag(FEATURES.MESSAGE_RECEIPTS_ENABLED); // message receipts enabled by default 38 | this.messageReceiptThrottleTime = DEFAULT_MESSAGE_RECEIPTS_THROTTLE_MS; 39 | this.featureChangeListeners = []; 40 | this.customUserAgentSuffix = ""; 41 | } 42 | update(configInput) { 43 | var config = configInput || {}; 44 | this.stage = config.stage || this.stage; 45 | this.region = config.region || this.region; 46 | this.cell = config.cell || this.cell; 47 | this.endpointOverride = config.endpoint || this.endpointOverride; 48 | this.reconnect = config.reconnect === false ? false : this.reconnect; 49 | this.messageReceiptThrottleTime = config.throttleTime ? config.throttleTime : 5000; 50 | // update features only if features is of type array. 51 | const features = Array.isArray(config.features) ? config.features : this.features.values; 52 | this.features["values"] = Array.isArray(features) ? [...features] : new Array(); 53 | this.customUserAgentSuffix = config.customUserAgentSuffix || this.customUserAgentSuffix; 54 | } 55 | 56 | updateStageRegionCell(config) { 57 | if (config) { 58 | this.stage = config.stage || this.stage; 59 | this.region = config.region || this.region; 60 | this.cell = config.cell || this.cell; 61 | } 62 | } 63 | 64 | getCell() { 65 | return this.cell; 66 | } 67 | 68 | updateThrottleTime(throttleTime) { 69 | this.messageReceiptThrottleTime = throttleTime || this.messageReceiptThrottleTime; 70 | } 71 | 72 | updateRegionOverride(regionOverride) { 73 | this.regionOverride = regionOverride; 74 | } 75 | 76 | getMessageReceiptsThrottleTime() { 77 | return this.messageReceiptThrottleTime; 78 | } 79 | 80 | getStage() { 81 | return this.stage; 82 | } 83 | 84 | getRegion() { 85 | return this.region; 86 | } 87 | 88 | getRegionOverride() { 89 | return this.regionOverride; 90 | } 91 | 92 | getCustomUserAgentSuffix() { 93 | return this.customUserAgentSuffix; 94 | } 95 | 96 | getEndpointOverride() { 97 | return this.endpointOverride; 98 | } 99 | 100 | removeFeatureFlag(feature) { 101 | if (!this.isFeatureEnabled(feature)) { 102 | return; 103 | } 104 | const index = this.features["values"].indexOf(feature); 105 | this.features["values"].splice(index, 1); 106 | } 107 | 108 | setFeatureFlag(feature) { 109 | if(this.isFeatureEnabled(feature)) { 110 | return; 111 | } 112 | const featureValues = Array.isArray(this.features["values"]) ? this.features["values"] : []; 113 | this.features["values"] = [...featureValues, feature]; 114 | } 115 | 116 | //private method 117 | _registerFeatureChangeListener(feature, callback) { 118 | if (!this.featureChangeListeners[feature]) { 119 | this.featureChangeListeners[feature] = []; 120 | } 121 | this.featureChangeListeners[feature].push(callback); 122 | } 123 | 124 | //private method 125 | _cleanFeatureChangeListener(feature) { 126 | delete this.featureChangeListeners[feature]; 127 | } 128 | 129 | isFeatureEnabled(feature, callback) { 130 | if(Array.isArray(this.features["values"]) && 131 | this.features["values"].indexOf(feature) !== -1) { 132 | if (typeof callback === "function") { 133 | return callback(); 134 | } 135 | return true; 136 | } 137 | if (typeof callback === "function") { 138 | this._registerFeatureChangeListener(feature, callback); 139 | } 140 | return false; 141 | } 142 | } 143 | 144 | const GlobalConfig = new GlobalConfigImpl(); 145 | 146 | export { GlobalConfig }; 147 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /*eslint no-unused-vars: "off"*/ 2 | import { ChatSessionObject } from "./core/chatSession"; 3 | import { LogManager, LogLevel } from "./log"; 4 | import { getMetadata } from "./metadata"; 5 | 6 | var global = typeof global !== 'undefined' ? global : 7 | typeof self !== 'undefined' ? self : 8 | typeof window !== 'undefined' ? window : {}; 9 | /** 10 | * connect is a shared namespace used by Streams SDK, 11 | * so any property added to connect namespace overrides 12 | * Streams SDK. Since Streams is initialized before ChatJS, 13 | * adding optionals here so Streams SDK namespace is not overridden. 14 | * */ 15 | global.connect = global.connect || {}; 16 | connect.ChatSession = connect.ChatSession || ChatSessionObject; 17 | connect.LogManager = connect.LogManager || LogManager; 18 | connect.LogLevel = connect.LogLevel || LogLevel; 19 | connect.csmService = connect.csmService || ChatSessionObject.csmService; 20 | export const ChatSession = ChatSessionObject; 21 | 22 | // Expose READ-ONLY global window.connect.ChatJS.version 23 | Object.defineProperty(global.connect, 'ChatJS', { 24 | value: Object.freeze(getMetadata()), 25 | writable: false, 26 | enumerable: true, 27 | configurable: false 28 | }); 29 | -------------------------------------------------------------------------------- /src/index.spec.js: -------------------------------------------------------------------------------- 1 | import { ChatSession } from "./index"; 2 | import pkg from '../package.json'; 3 | 4 | const ChatSessionObject = { 5 | csmService: "csmService" 6 | }; 7 | const LogLevel = "LogLevel"; 8 | const LogManager = "LogManager"; 9 | 10 | jest.mock("./core/chatSession", () => ({ 11 | ChatSessionObject : ChatSessionObject 12 | })); 13 | jest.mock("./log", () => ({ 14 | LogManager, 15 | LogLevel, 16 | })); 17 | 18 | describe("Chat JS index file", () => { 19 | test("ChatSession should equal ChatSessionObject", () => { 20 | expect(ChatSession).toEqual(ChatSessionObject); 21 | }); 22 | 23 | test("should mutate global connect object", () => { 24 | expect(global.connect.ChatSession).toEqual(ChatSessionObject); 25 | expect(global.connect.LogManager).toEqual(LogManager); 26 | expect(global.connect.LogLevel).toEqual(LogLevel); 27 | expect(global.connect.csmService).toEqual("csmService"); 28 | }); 29 | 30 | test('should expose ChatJS.version with correct version', () => { 31 | console.log(global.connect.ChatJS); 32 | expect(global.connect.ChatJS.version).toBeDefined(); 33 | expect(global.connect.ChatJS.version).toBe(process.env.npm_package_version); // "3.x.x" 34 | expect(global.connect.ChatJS.version).toBe(pkg.version); // "3.x.x" 35 | }); 36 | 37 | test('should not allow modification of ChatJS.version', () => { 38 | const originalValue = global.connect.ChatJS.version; 39 | 40 | expect(() => { 41 | global.connect.ChatJS.version = 'MODIFIED VERSION'; 42 | }).toThrow(TypeError); 43 | 44 | expect(global.connect.ChatJS.version).toBe(originalValue); 45 | expect(global.connect.ChatJS.version).not.toBe('MODIFIED VERSION'); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/log.js: -------------------------------------------------------------------------------- 1 | import Utils from "./utils"; 2 | 3 | /*eslint-disable no-unused-vars*/ 4 | class Logger { 5 | debug(data) {} 6 | 7 | info(data) {} 8 | 9 | warn(data) {} 10 | 11 | error(data) {} 12 | 13 | advancedLog(data) {} 14 | } 15 | /*eslint-enable no-unused-vars*/ 16 | 17 | const LogLevel = { 18 | DEBUG: 10, 19 | INFO: 20, 20 | WARN: 30, 21 | ERROR: 40, 22 | ADVANCED_LOG: 50, 23 | }; 24 | 25 | class LogManagerImpl { 26 | constructor() { 27 | this.updateLoggerConfig(); 28 | } 29 | 30 | writeToClientLogger(level, logStatement = '', logMetaData = '') { 31 | if (!this.hasClientLogger()) { 32 | return; 33 | } 34 | const log1 = typeof logStatement === "string" ? logStatement : JSON.stringify(logStatement, removeCircularReference()); 35 | const log2 = typeof logMetaData === "string" ? logMetaData : JSON.stringify(logMetaData, removeCircularReference()); 36 | const logStringValue = `${getLogLevelByValue(level)} ${log1} ${log2}`; 37 | switch (level) { 38 | case LogLevel.DEBUG: 39 | return this._clientLogger.debug(logStringValue) || logStringValue; 40 | case LogLevel.INFO: 41 | return this._clientLogger.info(logStringValue) || logStringValue; 42 | case LogLevel.WARN: 43 | return this._clientLogger.warn(logStringValue) || logStringValue; 44 | case LogLevel.ERROR: 45 | return this._clientLogger.error(logStringValue) || logStringValue; 46 | case LogLevel.ADVANCED_LOG: 47 | return this._advancedLogWriter && this._clientLogger[this._advancedLogWriter](logStringValue) || logStringValue; 48 | } 49 | } 50 | 51 | isLevelEnabled(level) { 52 | return level >= this._level; 53 | } 54 | 55 | hasClientLogger() { 56 | return this._clientLogger !== null; 57 | } 58 | 59 | getLogger(options = {}) { 60 | // option: {prefix: string; logMetaData: object} 61 | return new LoggerWrapperImpl(options); 62 | } 63 | 64 | updateLoggerConfig(inputConfig) { 65 | var config = inputConfig || {}; 66 | this._level = config.level || LogLevel.INFO; 67 | //enabled advancedLogWriter 68 | this._advancedLogWriter = "warn"; 69 | if (isValidAdvancedLogConfig(config.advancedLogWriter, config.customizedLogger)) { 70 | this._advancedLogWriter = config.advancedLogWriter; 71 | } 72 | //enable clientLogger 73 | if((config.customizedLogger && typeof config.customizedLogger === "object") || 74 | (config.logger && typeof config.logger === "object")) { 75 | this.useClientLogger = true; 76 | } 77 | this._clientLogger = this.selectLogger(config); 78 | } 79 | 80 | selectLogger(config) { 81 | if(config.customizedLogger && typeof config.customizedLogger === "object") { 82 | return config.customizedLogger; 83 | } 84 | if(config.logger && typeof config.logger === "object") { 85 | return config.logger; 86 | } 87 | if(config.useDefaultLogger) { 88 | return createConsoleLogger(); 89 | } 90 | return null; 91 | } 92 | } 93 | const LogManager = new LogManagerImpl(); 94 | 95 | class LoggerWrapper { 96 | debug() {} 97 | 98 | info() {} 99 | 100 | warn() {} 101 | 102 | error() {} 103 | } 104 | 105 | class LoggerWrapperImpl extends LoggerWrapper { 106 | constructor(options) { 107 | super(); 108 | this.options = options || {}; 109 | } 110 | 111 | debug(...args) { 112 | return this._log(LogLevel.DEBUG, args); 113 | } 114 | 115 | info(...args) { 116 | return this._log(LogLevel.INFO, args); 117 | } 118 | 119 | warn(...args) { 120 | return this._log(LogLevel.WARN, args); 121 | } 122 | 123 | error(...args) { 124 | return this._log(LogLevel.ERROR, args); 125 | } 126 | 127 | advancedLog(...args) { 128 | return this._log(LogLevel.ADVANCED_LOG, args); 129 | } 130 | 131 | _shouldLog(level) { 132 | return LogManager.hasClientLogger() && LogManager.isLevelEnabled(level); 133 | } 134 | 135 | _writeToClientLogger(level, logStatement) { 136 | return LogManager.writeToClientLogger(level, logStatement, this.options?.logMetaData); 137 | } 138 | 139 | _log(level, args) { 140 | if (this._shouldLog(level)) { 141 | var logStatement = LogManager.useClientLogger ? args : this._convertToSingleStatement(args); 142 | return this._writeToClientLogger(level, logStatement); 143 | } 144 | } 145 | 146 | _convertToSingleStatement(args) { 147 | var date = new Date(Date.now()).toISOString(); 148 | var logStatement = `[${date}]`; 149 | if (this.options) { 150 | this.options.prefix ? logStatement += " " + this.options.prefix + ":" : logStatement += ""; 151 | } 152 | for (var index = 0; index < args.length; index++) { 153 | var arg = args[index]; 154 | logStatement += " " + this._convertToString(arg); 155 | } 156 | return logStatement; 157 | } 158 | 159 | _convertToString(arg) { 160 | try { 161 | if (!arg) { 162 | return ""; 163 | } 164 | if (Utils.isString(arg)) { 165 | return arg; 166 | } 167 | if (Utils.isObject(arg) && Utils.isFunction(arg.toString)) { 168 | var toStringResult = arg.toString(); 169 | if (toStringResult !== "[object Object]") { 170 | return toStringResult; 171 | } 172 | } 173 | return JSON.stringify(arg); 174 | } catch (error) { 175 | console.error("Error while converting argument to string", arg, error); 176 | return ""; 177 | } 178 | } 179 | } 180 | 181 | function getLogLevelByValue(value) { 182 | switch(value) { 183 | case 10: return "DEBUG"; 184 | case 20: return "INFO"; 185 | case 30: return "WARN"; 186 | case 40: return "ERROR"; 187 | case 50: return "ADVANCED_LOG"; 188 | } 189 | } 190 | 191 | function isValidAdvancedLogConfig(advancedLogVal, customizedLogger) { 192 | const customizedLoggerKeys = customizedLogger && Object.keys(customizedLogger); 193 | if (customizedLoggerKeys && customizedLoggerKeys.indexOf(advancedLogVal) === -1) { 194 | console.error(`customizedLogger: incorrect value for loggerConfig:advancedLogWriter; use valid values from list ${customizedLoggerKeys} but used ${advancedLogVal}`); 195 | return false; 196 | } 197 | const defaultLoggerKeys = ["warn", "info", "debug", "log"]; 198 | if (advancedLogVal && defaultLoggerKeys.indexOf(advancedLogVal) === -1) { 199 | console.error(`incorrect value for loggerConfig:advancedLogWriter; use valid values from list ${defaultLoggerKeys} but used ${advancedLogVal}`); 200 | return false; 201 | } 202 | return true; 203 | } 204 | 205 | function removeCircularReference() { 206 | const seen = new WeakSet(); 207 | 208 | return (key, value) => { 209 | if (typeof value === "object" && value !== null) { 210 | if (seen.has(value)) { 211 | return; 212 | } 213 | seen.add(value); 214 | } 215 | return value; 216 | }; 217 | } 218 | 219 | var createConsoleLogger = () => { 220 | var logger = new LoggerWrapper(); 221 | logger.debug = console.debug.bind(window.console); 222 | logger.info = console.info.bind(window.console); 223 | logger.warn = console.warn.bind(window.console); 224 | logger.error = console.error.bind(window.console); 225 | return logger; 226 | }; 227 | 228 | 229 | export { LogManager, Logger, LogLevel }; 230 | -------------------------------------------------------------------------------- /src/metadata.js: -------------------------------------------------------------------------------- 1 | import pkg from '../package.json'; 2 | 3 | /** 4 | * Metadata information about the ChatJS library 5 | * Used to publish a client-side metrics (CSM) 6 | */ 7 | export const metadata = { 8 | version: process.env.npm_package_version || pkg.version, 9 | name: pkg.name, 10 | source: 'npm', 11 | repository: pkg.repository?.url || 'https://github.com/amazon-connect/amazon-connect-chatjs', 12 | build: { 13 | timestamp: new Date().toISOString(), 14 | environment: 'production' 15 | } 16 | }; 17 | 18 | // Expose the metadata with frozen object to prevent tampering 19 | export const getMetadata = () => Object.freeze({ ...metadata }); 20 | -------------------------------------------------------------------------------- /src/service/csmService.js: -------------------------------------------------------------------------------- 1 | import { GlobalConfig } from "../globalConfig"; 2 | import { 3 | getLdasEndpointUrl, 4 | CHAT_WIDGET_METRIC_NAME_SPACE, 5 | DEFAULT_WIDGET_TYPE 6 | } from "../config/csmConfig"; 7 | import { LogManager } from "../log"; 8 | import { csmJsString } from '../lib/connect-csm'; 9 | import { csmWorkerString } from '../lib/connect-csm-worker'; 10 | 11 | export const DIMENSION_CATEGORY = "Category"; 12 | class CsmService { 13 | constructor() { 14 | this.widgetType = DEFAULT_WIDGET_TYPE; 15 | this.logger = LogManager.getLogger({ 16 | prefix: "ChatJS-csmService" 17 | }); 18 | this.csmInitialized = false; 19 | this.metricsToBePublished = []; 20 | this.agentMetricToBePublished = []; 21 | this.MAX_RETRY = 5; 22 | } 23 | 24 | loadCsmScriptAndExecute() { 25 | try { 26 | let script = document.createElement('script'); 27 | script.type = 'text/javascript'; 28 | script.innerHTML = csmJsString; 29 | document.head.appendChild(script); 30 | this.initializeCSM(); 31 | } catch (error) { 32 | this.logger.error("Load csm script error: ", error); 33 | } 34 | } 35 | 36 | initializeCSM() { 37 | // avoid multiple initialization 38 | try { 39 | if (this.csmInitialized) { 40 | return; 41 | } 42 | const region = GlobalConfig.getRegionOverride() || GlobalConfig.getRegion(); 43 | const cell = GlobalConfig.getCell(); 44 | const csmWorkerText = csmWorkerString.replace(/\\/g, ''); 45 | const sharedWorkerBlobUrl = URL.createObjectURL(new Blob([csmWorkerText], { type: 'text/javascript' })); 46 | const ldasEndpoint = getLdasEndpointUrl(region); 47 | let params = { 48 | endpoint: ldasEndpoint, 49 | namespace: CHAT_WIDGET_METRIC_NAME_SPACE, 50 | sharedWorkerUrl: sharedWorkerBlobUrl, 51 | }; 52 | 53 | csm.initCSM(params); 54 | this.logger.info(`CSMService is initialized in ${region} cell-${cell}`); 55 | this.csmInitialized = true; 56 | if (this.metricsToBePublished) { 57 | this.metricsToBePublished.forEach((metric) => { 58 | csm.API.addMetric(metric); 59 | }); 60 | this.metricsToBePublished = null; 61 | } 62 | } catch(err) { 63 | this.logger.error('Failed to initialize csm: ', err); 64 | } 65 | } 66 | 67 | updateCsmConfig(csmConfig) { 68 | this.widgetType = typeof csmConfig === "object" && csmConfig !== null && !Array.isArray(csmConfig) ? 69 | csmConfig.widgetType : this.widgetType; 70 | } 71 | 72 | _hasCSMFailedToImport() { 73 | return typeof csm === 'undefined'; 74 | } 75 | 76 | getDefaultDimensions() { 77 | return [ 78 | { 79 | name: "WidgetType", 80 | value: this.widgetType 81 | } 82 | ]; 83 | } 84 | 85 | addMetric(metric) { 86 | if (this._hasCSMFailedToImport()) return; 87 | 88 | // if csmService is never initialized, store the metrics in an array 89 | if (!this.csmInitialized) { 90 | if (this.metricsToBePublished) { 91 | this.metricsToBePublished.push(metric); 92 | this.logger.info(`CSMService is not initialized yet. Adding metrics to queue to be published once CSMService is initialized`); 93 | } 94 | } else { 95 | try { 96 | csm.API.addMetric(metric); 97 | } catch(err) { 98 | this.logger.error('Failed to addMetric csm: ', err); 99 | } 100 | } 101 | } 102 | 103 | setDimensions(metric, dimensions) { 104 | dimensions.forEach((dimension) => { 105 | metric.addDimension(dimension.name, dimension.value); 106 | }); 107 | } 108 | 109 | addLatencyMetric(method, timeDifference, category, otherDimensions = []) { 110 | if (this._hasCSMFailedToImport()) return; 111 | 112 | try { 113 | const latencyMetric = new csm.Metric( 114 | method, 115 | csm.UNIT.MILLISECONDS, 116 | timeDifference 117 | ); 118 | const dimensions = [ 119 | ...this.getDefaultDimensions(), 120 | { 121 | name: "Metric", 122 | value: "Latency", 123 | }, 124 | { 125 | name: DIMENSION_CATEGORY, 126 | value: category 127 | }, 128 | ...otherDimensions 129 | ]; 130 | this.setDimensions(latencyMetric, dimensions); 131 | this.addMetric(latencyMetric); 132 | this.logger.debug(`Successfully published latency API metrics for method ${method}`); 133 | } catch (err) { 134 | this.logger.error('Failed to addLatencyMetric csm: ', err); 135 | } 136 | } 137 | 138 | addLatencyMetricWithStartTime(method, startTime, category, otherDimensions = []) { 139 | const endTime = new Date().getTime(); 140 | const timeDifference = endTime - startTime; 141 | this.addLatencyMetric(method, timeDifference, category, otherDimensions); 142 | this.logger.debug(`Successfully published latency API metrics for method ${method}`); 143 | } 144 | 145 | addCountAndErrorMetric(method, category, error, otherDimensions = []) { 146 | if (this._hasCSMFailedToImport()) return; 147 | 148 | try { 149 | const dimensions = [ 150 | ...this.getDefaultDimensions(), 151 | { 152 | name: DIMENSION_CATEGORY, 153 | value: category 154 | }, 155 | ...otherDimensions 156 | ]; 157 | const countMetric = new csm.Metric(method, csm.UNIT.COUNT, 1); 158 | this.setDimensions(countMetric, [ 159 | ...dimensions, 160 | { 161 | name: "Metric", 162 | value: "Count", 163 | } 164 | ]); 165 | const errorCount = error ? 1 : 0; 166 | const errorMetric = new csm.Metric(method, csm.UNIT.COUNT, errorCount); 167 | this.setDimensions(errorMetric, [ 168 | ...dimensions, 169 | { 170 | name: "Metric", 171 | value: "Error", 172 | } 173 | ]); 174 | this.addMetric(countMetric); 175 | this.addMetric(errorMetric); 176 | this.logger.debug(`Successfully published count and error metrics for method ${method}`); 177 | } catch(err) { 178 | this.logger.error('Failed to addCountAndErrorMetric csm: ', err); 179 | } 180 | } 181 | 182 | addCountMetric(method, category, otherDimensions = []) { 183 | if (this._hasCSMFailedToImport()) return; 184 | 185 | try { 186 | const dimensions = [ 187 | ...this.getDefaultDimensions(), 188 | { 189 | name: DIMENSION_CATEGORY, 190 | value: category 191 | }, 192 | { 193 | name: "Metric", 194 | value: "Count", 195 | }, 196 | ...otherDimensions 197 | ]; 198 | const countMetric = new csm.Metric(method, csm.UNIT.COUNT, 1); 199 | this.setDimensions(countMetric, dimensions); 200 | this.addMetric(countMetric); 201 | this.logger.debug(`Successfully published count metrics for method ${method}`); 202 | } catch(err) { 203 | this.logger.error('Failed to addCountMetric csm: ', err); 204 | } 205 | } 206 | 207 | addAgentCountMetric(metricName, count) { 208 | if (this._hasCSMFailedToImport()) return; 209 | 210 | try { 211 | const _self = this; 212 | if (csm && csm.API.addCount && metricName) { 213 | csm.API.addCount(metricName, count); 214 | _self.MAX_RETRY = 5; 215 | } else { 216 | //add to list and retry later 217 | if (metricName) { 218 | this.agentMetricToBePublished.push({ 219 | 220 | metricName, 221 | 222 | count 223 | }); 224 | } 225 | setTimeout(() => { 226 | if (csm && csm.API.addCount) { 227 | this.agentMetricToBePublished.forEach(metricItem => { 228 | csm.API.addCount(metricItem.metricName, metricItem.count); 229 | }); 230 | this.agentMetricToBePublished = []; 231 | } else if(_self.MAX_RETRY > 0) { 232 | _self.MAX_RETRY -= 1; 233 | _self.addAgentCountMetric(); 234 | } 235 | }, 3000); 236 | } 237 | } catch(err) { 238 | this.logger.error('Failed to addAgentCountMetric csm: ', err); 239 | } 240 | } 241 | } 242 | 243 | const csmService = new CsmService(); 244 | export { csmService }; -------------------------------------------------------------------------------- /src/service/csmService.spec.js: -------------------------------------------------------------------------------- 1 | import { csmService, DIMENSION_CATEGORY } from "./csmService"; 2 | import { 3 | CHAT_WIDGET_METRIC_NAME_SPACE, 4 | DEFAULT_WIDGET_TYPE, 5 | } from "../config/csmConfig"; 6 | import * as CsmConfig from "../config/csmConfig"; 7 | import { GlobalConfig } from "../globalConfig"; 8 | import { ChatSessionObject } from "../core/chatSession"; 9 | 10 | jest.useFakeTimers(); 11 | jest.spyOn(global, 'setTimeout'); 12 | const mockCsmConfig = { 13 | widgetType: "test-widget-type" 14 | }; 15 | 16 | const mockCategory = "test-category"; 17 | const mockMethod = "test-method"; 18 | const mockStartTime = 0; 19 | const mockError = 0; 20 | const mockCsmUnit = { 21 | COUNT: 'Count', 22 | SECONDS: 'Seconds', 23 | MILLISECONDS: 'Milliseconds', 24 | MICROSECONDS: 'Microseconds', 25 | }; 26 | const mockObjectUrl = "test-object-url"; 27 | const mockLdasEndpoint = "test-ldas-endpoint"; 28 | const mockOtherDimension = [ 29 | { 30 | name: "otherKey", 31 | value: "otherValue" 32 | } 33 | ]; 34 | 35 | describe("Common csmService tests", () => { 36 | let mockAddDimension, mockCsmMetric, mockAddMetric, mockInitCSM, csmMetric; 37 | 38 | beforeEach(() => { 39 | jest.resetAllMocks(); 40 | GlobalConfig.updateStageRegionCell({ 41 | stage: "test", 42 | region: "us-west-2", 43 | }); 44 | mockAddDimension = jest.fn(); 45 | mockCsmMetric = jest.fn(); 46 | mockAddMetric = jest.fn(); 47 | mockInitCSM = jest.fn(); 48 | csmMetric = { 49 | addDimension: mockAddDimension 50 | }; 51 | jest.spyOn(CsmConfig, 'getLdasEndpointUrl').mockReturnValue(mockLdasEndpoint); 52 | 53 | global.URL.createObjectURL = jest.fn().mockReturnValue(mockObjectUrl); 54 | global.fetch = () => 55 | Promise.resolve({ 56 | text: () => Promise.resolve([]), 57 | }); 58 | global.csm = { 59 | initCSM: mockInitCSM, 60 | UNIT: mockCsmUnit, 61 | Metric: mockCsmMetric.mockImplementation(() => { 62 | return csmMetric; 63 | }), 64 | API: { 65 | addMetric: mockAddMetric 66 | }, 67 | }; 68 | process.env = {}; 69 | csmService.csmInitialized = false; 70 | csmService.metricsToBePublished = []; 71 | }); 72 | 73 | describe("normal cases", () => { 74 | beforeEach(() => { 75 | csmService.initializeCSM(); 76 | }); 77 | 78 | it("should be able to initialize CSM", () => { 79 | expect(csmService.widgetType).toBe(DEFAULT_WIDGET_TYPE); 80 | expect(mockInitCSM).toHaveBeenCalledWith({ 81 | endpoint: mockLdasEndpoint, 82 | namespace: CHAT_WIDGET_METRIC_NAME_SPACE, 83 | sharedWorkerUrl: mockObjectUrl, 84 | }); 85 | }); 86 | 87 | it("should only initialize CSM once", () => { 88 | expect(csmService.csmInitialized).toBe(true); 89 | csmService.initializeCSM(); 90 | expect(mockInitCSM).toHaveBeenCalledTimes(1); 91 | }); 92 | 93 | it("should be able to update CSM config", () => { 94 | expect(csmService.csmInitialized).toBe(true); 95 | csmService.updateCsmConfig(mockCsmConfig); 96 | expect(csmService.widgetType).toBe(mockCsmConfig.widgetType); 97 | }); 98 | 99 | it("should be able to add latency metrics", () => { 100 | expect(csmService.csmInitialized).toBe(true); 101 | csmService.addLatencyMetricWithStartTime(mockMethod, mockStartTime, mockCategory, mockOtherDimension); 102 | expect(mockAddDimension).toHaveBeenCalledTimes(4); 103 | expect(mockAddDimension).toHaveBeenNthCalledWith(1, "WidgetType", mockCsmConfig.widgetType); 104 | expect(mockAddDimension).toHaveBeenNthCalledWith(2, "Metric", "Latency"); 105 | expect(mockAddDimension).toHaveBeenNthCalledWith(3, DIMENSION_CATEGORY, mockCategory); 106 | expect(mockAddDimension).toHaveBeenNthCalledWith(4, "otherKey", "otherValue"); 107 | expect(mockCsmMetric).toHaveBeenCalledWith(mockMethod, csm.UNIT.MILLISECONDS, expect.anything()); 108 | expect(mockAddMetric).toHaveBeenCalledWith(csmMetric); 109 | }); 110 | 111 | it("should be able to add error and count metrics", () => { 112 | expect(csmService.csmInitialized).toBe(true); 113 | csmService.addCountAndErrorMetric(mockMethod, mockCategory, mockError, mockOtherDimension); 114 | expect(mockAddDimension).toHaveBeenCalledTimes(8); 115 | expect(mockAddDimension).toHaveBeenNthCalledWith(1, "WidgetType", mockCsmConfig.widgetType); 116 | expect(mockAddDimension).toHaveBeenNthCalledWith(2, DIMENSION_CATEGORY, mockCategory); 117 | expect(mockAddDimension).toHaveBeenNthCalledWith(3, "otherKey", "otherValue"); 118 | expect(mockAddDimension).toHaveBeenNthCalledWith(4, "Metric", "Count"); 119 | expect(mockAddDimension).toHaveBeenNthCalledWith(5, "WidgetType", mockCsmConfig.widgetType); 120 | expect(mockAddDimension).toHaveBeenNthCalledWith(6, DIMENSION_CATEGORY, mockCategory); 121 | expect(mockAddDimension).toHaveBeenNthCalledWith(7, "otherKey", "otherValue"); 122 | expect(mockAddDimension).toHaveBeenNthCalledWith(8, "Metric", "Error"); 123 | expect(mockAddDimension).toHaveBeenCalledWith("WidgetType", mockCsmConfig.widgetType); 124 | expect(mockCsmMetric).toHaveBeenNthCalledWith(1, mockMethod, csm.UNIT.COUNT, 1); 125 | expect(mockCsmMetric).toHaveBeenNthCalledWith(2, mockMethod, csm.UNIT.COUNT, mockError); 126 | expect(mockAddMetric).toHaveBeenNthCalledWith(1, csmMetric); 127 | expect(mockAddMetric).toHaveBeenNthCalledWith(2, csmMetric); 128 | }); 129 | 130 | it("should be able to add count metrics", () => { 131 | expect(csmService.csmInitialized).toBe(true); 132 | csmService.addCountMetric(mockMethod, mockCategory, mockOtherDimension); 133 | expect(mockAddDimension).toHaveBeenCalledTimes(4); 134 | expect(mockAddDimension).toHaveBeenNthCalledWith(1, "WidgetType", mockCsmConfig.widgetType); 135 | expect(mockAddDimension).toHaveBeenNthCalledWith(2, DIMENSION_CATEGORY, mockCategory); 136 | expect(mockAddDimension).toHaveBeenNthCalledWith(3, "Metric", "Count"); 137 | expect(mockAddDimension).toHaveBeenNthCalledWith(4, "otherKey", "otherValue"); 138 | expect(mockCsmMetric).toHaveBeenCalledWith(mockMethod, csm.UNIT.COUNT, 1); 139 | expect(mockAddMetric).toHaveBeenCalledWith(csmMetric); 140 | }); 141 | }); 142 | 143 | describe("publish metrics before csmService is initialized", () => { 144 | beforeEach(() => { 145 | csmService.addLatencyMetricWithStartTime(mockMethod, mockStartTime, mockCategory); 146 | csmService.addCountAndErrorMetric(mockMethod, mockCategory, mockError); 147 | csmService.addCountMetric(mockMethod, mockCategory); 148 | expect(csmService.metricsToBePublished).toHaveLength(4); 149 | expect(mockInitCSM).not.toHaveBeenCalled(); 150 | expect(mockAddMetric).not.toHaveBeenCalled(); 151 | csmService.initializeCSM(); 152 | }); 153 | 154 | it("metrics queue should be cleared once CSM is initialized", () => { 155 | csmService.addLatencyMetricWithStartTime(mockMethod, mockStartTime, mockCategory); 156 | csmService.addCountAndErrorMetric(mockMethod, mockCategory, mockError); 157 | csmService.addCountMetric(mockMethod, mockCategory); 158 | expect(csmService.metricsToBePublished).toBeNull(); 159 | }); 160 | 161 | it("should not be able to publish metrics before csmService is initialized, which should be then published once csmService is initialized", () => { 162 | expect(mockInitCSM).toHaveBeenCalled(); 163 | expect(mockAddMetric).toHaveBeenCalledTimes(4); 164 | }); 165 | }); 166 | 167 | describe("publish metric for Agent", () => { 168 | it("should call addCount to add metric", () => { 169 | csm.API.addCount = jest.fn(); 170 | csmService.addAgentCountMetric("test", 1); 171 | expect(csm.API.addCount).toHaveBeenCalled(); 172 | expect(csm.API.addCount).toHaveBeenCalledWith("test", 1); 173 | }); 174 | it("should call addCount to add metric", () => { 175 | csm.API.addCount = undefined; 176 | csmService.addAgentCountMetric("test", 1); 177 | expect(csmService.agentMetricToBePublished).toEqual([{ 178 | count: 1, 179 | metricName: "test" 180 | }]); 181 | expect(setTimeout).toHaveBeenCalledTimes(1); 182 | expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 3000); 183 | expect(csmService.MAX_RETRY).toEqual(5); 184 | while(csmService.MAX_RETRY > 0) { 185 | setTimeout.mock.calls[5-csmService.MAX_RETRY][0](); 186 | } 187 | expect(csmService.MAX_RETRY).toEqual(0); 188 | csm.API.addCount = jest.fn(); 189 | setTimeout.mock.calls[0][0](); 190 | expect(csm.API.addCount).toHaveBeenCalled(); 191 | expect(csm.API.addCount).toHaveBeenCalledWith("test", 1); 192 | }); 193 | }); 194 | }); 195 | 196 | describe("Disabling CSM", () => { 197 | let backupGlobalCSM = global.csm; 198 | 199 | const chatSessionCreateInput = { 200 | loggerConfig: { useDefaultLogger: true }, 201 | // disableCSM: false, // default false 202 | chatDetails: { 203 | ContactId: 'abc', 204 | ParticipantId: 'abc', 205 | ParticipantToken: 'abc', 206 | }, 207 | type: "CUSTOMER" // <-- IMPORTANT 208 | }; 209 | 210 | beforeEach(() => { 211 | jest.resetAllMocks(); 212 | jest.spyOn(csmService, 'addLatencyMetricWithStartTime'); 213 | jest.spyOn(csmService, 'addMetric'); 214 | jest.spyOn(csmService.logger, 'error'); 215 | jest.spyOn(csmService.logger, 'info'); 216 | 217 | // Mock these for `lib/connect-csm-worker.js` 218 | global.URL.createObjectURL = jest.fn(() => 'mock_sharedWorkerBlobUrl'); 219 | global.SharedWorker = jest.fn(() => ({ 220 | port: { 221 | start: jest.fn(), 222 | postMessage: jest.fn(), 223 | }, 224 | })); 225 | 226 | // Remove existing "csm" value from other test files 227 | delete global.csm; 228 | }); 229 | 230 | afterEach(() => { 231 | global.csm = backupGlobalCSM; 232 | }); 233 | 234 | it("should not execute addMetric when CSM has been disabled", () => { 235 | let backup_loadCsmScriptAndExecute = csmService.loadCsmScriptAndExecute; 236 | let backup_initializeCSM = csmService.initializeCSM; 237 | 238 | csmService.loadCsmScriptAndExecute = jest.fn(); 239 | csmService.initializeCSM = jest.fn(); 240 | 241 | // Initialize chat session with CSM disabled 242 | ChatSessionObject.create({ 243 | ...chatSessionCreateInput, 244 | disableCSM: true, // <-- IMPORTANT 245 | }); 246 | expect(csmService.loadCsmScriptAndExecute).not.toHaveBeenCalled(); 247 | expect(csmService.initializeCSM).not.toHaveBeenCalled(); 248 | expect(csmService.csmInitialized).toBe(false); 249 | 250 | // Attempt to publish a metric 251 | csmService.addLatencyMetricWithStartTime("test-method", 0, "test-category"); 252 | csmService.addCountAndErrorMetric("test-method", "test-category", "test-error"); 253 | csmService.addCountMetric("test-method", "test-category"); 254 | csmService.addAgentCountMetric("test-name", 0); 255 | 256 | // Assert expected behavior 257 | expect(csmService.addMetric).not.toHaveBeenCalled(); 258 | expect(csmService.logger.error).not.toHaveBeenCalled(); // Should be silent and not output any ERROR logs 259 | expect(csmService.logger.info).not.toHaveBeenCalled(); // Should be silent and not output any INFO logs 260 | 261 | // Cleanup 262 | csmService.loadCsmScriptAndExecute = backup_loadCsmScriptAndExecute; 263 | csmService.initializeCSM = backup_initializeCSM; 264 | }); 265 | 266 | it("should properly execute addMetric when CSM is enabled", () => { 267 | jest.spyOn(CsmConfig, 'getLdasEndpointUrl').mockReturnValue(mockLdasEndpoint); 268 | 269 | // Initialize chat session with CSM disabled 270 | expect(csmService.csmInitialized).toBe(false); 271 | ChatSessionObject.create({ 272 | ...chatSessionCreateInput, 273 | disableCSM: false, // <-- default "true" 274 | }); 275 | expect(csmService.logger.error).not.toHaveBeenCalled(); // Should not trigger "catch" block 276 | expect(csmService.csmInitialized).toBe(true); 277 | 278 | // Attempt to publish a metric 279 | csmService.addLatencyMetricWithStartTime("test-method", 0, "test-category"); 280 | csmService.addCountAndErrorMetric("test-method", "test-category", "test-error"); 281 | csmService.addCountMetric("test-method", "test-category"); 282 | csmService.addAgentCountMetric("test-name", 0); 283 | 284 | // Assert expected behavior 285 | expect(csmService.addLatencyMetricWithStartTime).toHaveBeenCalled(); 286 | expect(csmService.logger.error).not.toHaveBeenCalled(); // Should not trigger "catch" block 287 | expect(csmService.addMetric).toHaveBeenCalled(); 288 | expect(csmService.logger.error).not.toHaveBeenCalled(); // Should not trigger "catch" block 289 | }); 290 | }); 291 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { IllegalArgumentException } from "./core/exceptions"; 2 | import { ValueError } from "./core/exceptions"; 3 | import { sprintf } from "sprintf-js"; 4 | import { CONTENT_TYPE } from "./constants"; 5 | const Utils = {}; 6 | 7 | /** 8 | * Asserts that a premise is true. 9 | */ 10 | Utils.assertTrue = function(premise, message) { 11 | if (!premise) { 12 | throw new ValueError(message); 13 | } 14 | }; 15 | 16 | /** 17 | * Asserts that a value is not null or undefined. 18 | */ 19 | Utils.assertNotNull = function(value, name) { 20 | Utils.assertTrue( 21 | value !== null && typeof value !== "undefined", 22 | sprintf("%s must be provided", name || "A value") 23 | ); 24 | return value; 25 | }; 26 | 27 | Utils.now = function() { 28 | return new Date().getTime(); 29 | }; 30 | 31 | Utils.isString = function(value) { 32 | return typeof value === "string"; 33 | }; 34 | 35 | /** 36 | * Generate a random ID consisting of the current timestamp 37 | * and a random base-36 number based on Math.random(). 38 | */ 39 | Utils.randomId = function() { 40 | return sprintf( 41 | "%s-%s", 42 | Utils.now(), 43 | Math.random() 44 | .toString(36) 45 | .slice(2) 46 | ); 47 | }; 48 | 49 | Utils.assertIsNonEmptyString = function(value, key) { 50 | if (!value || typeof value !== "string") { 51 | throw new IllegalArgumentException(key + " is not a non-empty string!"); 52 | } 53 | }; 54 | 55 | Utils.assertIsList = function(value, key) { 56 | if (!Array.isArray(value)) { 57 | throw new IllegalArgumentException(key + " is not an array"); 58 | } 59 | }; 60 | 61 | Utils.assertIsEnum = function(value, allowedValues, key) { 62 | var i; 63 | for (i = 0; i < allowedValues.length; i++) { 64 | if (allowedValues[i] === value) { 65 | return; 66 | } 67 | } 68 | throw new IllegalArgumentException( 69 | key + " passed (" + value + ")" + " is not valid. Allowed values are: " + allowedValues 70 | ); 71 | }; 72 | 73 | /** 74 | * Generate an enum from the given list of lower-case enum values, 75 | * where the enum keys will be upper case. 76 | * 77 | * Conversion from pascal case based on code from here: 78 | * http://stackoverflow.com/questions/30521224 79 | */ 80 | Utils.makeEnum = function(values) { 81 | var enumObj = {}; 82 | 83 | values.forEach(function(value) { 84 | var key = value 85 | .replace(/\.?([a-z]+)_?/g, function(x, y) { 86 | return y.toUpperCase() + "_"; 87 | }) 88 | .replace(/_$/, ""); 89 | 90 | enumObj[key] = value; 91 | }); 92 | 93 | return enumObj; 94 | }; 95 | 96 | Utils.contains = function(obj, value) { 97 | if (obj instanceof Array) { 98 | return ( 99 | Utils.find(obj, function(v) { 100 | return v === value; 101 | }) !== null 102 | ); 103 | } else { 104 | return value in obj; 105 | } 106 | }; 107 | 108 | Utils.find = function(array, predicate) { 109 | for (var x = 0; x < array.length; x++) { 110 | if (predicate(array[x])) { 111 | return array[x]; 112 | } 113 | } 114 | 115 | return null; 116 | }; 117 | 118 | Utils.containsValue = function(obj, value) { 119 | if (obj instanceof Array) { 120 | return ( 121 | Utils.find(obj, function(v) { 122 | return v === value; 123 | }) !== null 124 | ); 125 | } else { 126 | return ( 127 | Utils.find(Utils.values(obj), function(v) { 128 | return v === value; 129 | }) !== null 130 | ); 131 | } 132 | }; 133 | 134 | /** 135 | * Determine if the given value is a callable function type. 136 | * Borrowed from Underscore.js. 137 | */ 138 | Utils.isFunction = function(obj) { 139 | return !!(obj && obj.constructor && obj.call && obj.apply); 140 | }; 141 | 142 | /** 143 | * Get a list of values from a Javascript object used 144 | * as a hash map. 145 | */ 146 | Utils.values = function(map) { 147 | var values = []; 148 | 149 | Utils.assertNotNull(map, "map"); 150 | 151 | for (var k in map) { 152 | values.push(map[k]); 153 | } 154 | 155 | return values; 156 | }; 157 | 158 | Utils.isObject = function(value) { 159 | return !(typeof value !== "object" || value === null); 160 | }; 161 | 162 | Utils.assertIsObject = function(value, key) { 163 | if (!Utils.isObject(value)) { 164 | throw new IllegalArgumentException(key + " is not an object!"); 165 | } 166 | }; 167 | 168 | Utils.delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); 169 | 170 | Utils.asyncWhileInterval = function(f, predicate, interval, count=0, error=null) { 171 | const now = new Date(); 172 | if (predicate(count)) { 173 | return f(count).catch((e) => { 174 | const delay = Math.max(0, interval - (new Date()).valueOf() + now.valueOf()); 175 | return Utils 176 | .delay(delay) 177 | .then(() => Utils.asyncWhileInterval(f, predicate, interval, count + 1, e)); 178 | }); 179 | } else { 180 | return Promise.reject(error || new Error("async while aborted")); 181 | } 182 | }; 183 | 184 | Utils.isAttachmentContentType = function(contentType){ 185 | return contentType === CONTENT_TYPE.applicationPdf 186 | || contentType === CONTENT_TYPE.imageJpg 187 | || contentType === CONTENT_TYPE.imagePng 188 | || contentType === CONTENT_TYPE.applicationDoc 189 | || contentType === CONTENT_TYPE.applicationXls 190 | || contentType === CONTENT_TYPE.applicationPpt 191 | || contentType === CONTENT_TYPE.textCsv 192 | || contentType === CONTENT_TYPE.audioWav; 193 | }; 194 | 195 | export default Utils; 196 | -------------------------------------------------------------------------------- /src/utils.spec.js: -------------------------------------------------------------------------------- 1 | import Utils from "./utils"; 2 | import { IllegalArgumentException } from "./core/exceptions"; 3 | 4 | describe("Utils", () => { 5 | describe(".delay()", () => { 6 | 7 | const delay = 1000; 8 | 9 | beforeEach(() => { 10 | jest.useFakeTimers(); 11 | }); 12 | 13 | afterEach(() => { 14 | jest.useRealTimers(); 15 | }); 16 | 17 | it("returns Promise that is resolved after X ms", () => { 18 | const fn = jest.fn(); 19 | Utils.delay(delay).then(fn); 20 | jest.advanceTimersByTime(delay); 21 | Promise.resolve().then(() => { 22 | expect(fn).toHaveBeenCalled(); 23 | }); 24 | }); 25 | 26 | it("returns Promise that is not resolved after X-1 ms", () => { 27 | const fn = jest.fn(); 28 | Utils.delay(delay).then(fn); 29 | jest.advanceTimersByTime(delay - 1); 30 | Promise.resolve().then(() => { 31 | expect(fn).not.toHaveBeenCalled(); 32 | }); 33 | }); 34 | }); 35 | 36 | describe(".asyncWhileInterval()", () => { 37 | 38 | async function runTimersWithPromises() { 39 | await Promise.resolve(); 40 | jest.runAllTimers(); 41 | await Promise.resolve(); 42 | } 43 | 44 | it("returns promise that resolves if first inner function resolves", () => { 45 | const promise = Utils.asyncWhileInterval(() => { 46 | return Promise.resolve('ok'); 47 | }, (count) => count < 5, 0); 48 | expect(promise).resolves.toBe('ok'); 49 | }); 50 | 51 | it("returns promise that resolves if ANY inner function resolves", () => { 52 | const promise = Utils.asyncWhileInterval((counter) => { 53 | return counter === 3 ? Promise.resolve('ok') : Promise.reject(); 54 | }, (count) => count < 5, 0); 55 | expect(promise).resolves.toBe('ok'); 56 | }); 57 | 58 | it("repeats execution until a resolved promise is returned", async () => { 59 | let numberOfExecutions = 0; 60 | await Utils.asyncWhileInterval((counter) => { 61 | numberOfExecutions += 1; 62 | return counter === 3 ? Promise.resolve('ok') : Promise.reject(); 63 | }, (count) => count < 5, 0); 64 | expect(numberOfExecutions).toBe(4); 65 | }); 66 | 67 | it("returns promise that rejects if all inner functions reject", () => { 68 | const promise = Utils.asyncWhileInterval(() => { 69 | return Promise.reject(); 70 | }, (count) => count < 5, 0); 71 | expect(promise).rejects.toBeInstanceOf(Error); 72 | }); 73 | 74 | it("applies delay correctly", async () => { 75 | jest.useFakeTimers(); 76 | let date = 0; 77 | let numberOfExecutions = 0; 78 | /* eslint-disable no-global-assign */ 79 | Date = jest.fn(() => date); 80 | Utils.asyncWhileInterval(() => { 81 | numberOfExecutions += 1; 82 | return Promise.reject(); 83 | }, (count) => count < 5, 1000); 84 | jest.runAllTimers(); 85 | await Promise.resolve(); 86 | expect(numberOfExecutions).toBe(1); 87 | await runTimersWithPromises(); 88 | expect(numberOfExecutions).toBe(2); 89 | await runTimersWithPromises(); 90 | expect(numberOfExecutions).toBe(3); 91 | await runTimersWithPromises(); 92 | expect(numberOfExecutions).toBe(4); 93 | }); 94 | }); 95 | 96 | describe("assertIsList()", () => { 97 | it("throws IllegalArgumentException if no inputs", () => { 98 | expect(() => Utils.assertIsList()).toThrow(IllegalArgumentException); 99 | }); 100 | it("validates if input is a List", () => { 101 | expect(Utils.assertIsList([])).toEqual(undefined); 102 | }); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /test/jestSetup.js: -------------------------------------------------------------------------------- 1 | import regeneratorRuntime from "regenerator-runtime"; 2 | -------------------------------------------------------------------------------- /test/polyfills.js: -------------------------------------------------------------------------------- 1 | // Add `finally()` to `Promise.prototype` 2 | Promise.prototype.finally = function(onFinally) { 3 | return this.then( 4 | /* onFulfilled */ 5 | res => Promise.resolve(onFinally()).then(() => res), 6 | /* onRejected */ 7 | err => Promise.resolve(onFinally()).then(() => { throw err; }) 8 | ); 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "noEmit": true, 6 | "strict": true, 7 | }, 8 | "exclude": ["node_modules", "release", "dist"] 9 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 4 | 5 | module.exports = { 6 | entry: "./src/index", 7 | mode: "development", 8 | devtool: "source-map", 9 | node: { 10 | global: false 11 | }, 12 | output: { 13 | filename: "amazon-connect-chat.js", 14 | path: path.resolve(__dirname, "dist") 15 | }, 16 | resolve: { 17 | extensions: [".js"] 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.(js|mjs|jsx|ts|tsx)$/, 23 | exclude: /node_modules/, 24 | loader: require.resolve("babel-loader"), 25 | options: { 26 | // This is a feature of `babel-loader` for webpack (not Babel itself). 27 | // It enables caching results in ./node_modules/.cache/babel-loader/ 28 | // directory for faster rebuilds. 29 | cacheDirectory: true, 30 | // Don't waste time on Gzipping the cache 31 | cacheCompression: false 32 | } 33 | } 34 | ] 35 | }, 36 | 37 | plugins: [ 38 | // Expose ChatJS version 39 | // window.connect.ChatJSVersion = process.env.npm_package_version; 40 | new webpack.DefinePlugin({ 41 | 'process.env.npm_package_version': JSON.stringify(process.env.npm_package_version ?? 'live'), 42 | }), 43 | 44 | new CopyWebpackPlugin({ 45 | patterns: [ 46 | { 47 | from: path.resolve(__dirname, "src/index.d.ts"), 48 | to: path.resolve(__dirname, "dist") 49 | } 50 | ] 51 | }) 52 | ], 53 | 54 | devServer: { 55 | compress: false, 56 | hot: true, 57 | static: { 58 | directory: path.resolve(__dirname, "showcase"), 59 | watch: true 60 | } 61 | } 62 | }; 63 | --------------------------------------------------------------------------------