├── .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 |
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 |
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 |
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 |
77 |
78 |
79 | - Review the workflow
80 |
81 |
82 |
83 |
84 |
85 | 3. Run the dry-run workflow:
86 |
87 | - Approve the workflow
88 |
89 |
90 |
91 |
92 |
93 | - View Dry-run workflow results
94 |
95 |
96 |
97 |
98 |
99 | 4. Run the publish workflow:
100 |
101 | - Approve the publish workflow
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 | - View publish workflow results
112 |
113 |
114 |
115 |
116 |
117 | 5. View the live updated npm package
118 |
119 |
120 |
121 |
122 |
123 |
124 |
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 |
--------------------------------------------------------------------------------