@webex/plugin-meetings 2.13.0 → 2.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,7 @@ import Media from '../media';
16
16
  import MediaProperties from '../media/properties';
17
17
  import MeetingStateMachine from '../meeting/state';
18
18
  import createMuteState from '../meeting/muteState';
19
+ import createEffectsState from '../meeting/effectsState';
19
20
  import LocusInfo from '../locus-info';
20
21
  import PeerConnectionManager from '../peer-connection-manager';
21
22
  import Metrics from '../metrics';
@@ -531,6 +532,14 @@ export default class Meeting extends StatelessWebexPlugin {
531
532
  * @memberof Meeting
532
533
  */
533
534
  this.video = null;
535
+ /**
536
+ * created later
537
+ * @instance
538
+ * @type {EffectsState}
539
+ * @private
540
+ * @memberof Meeting
541
+ */
542
+ this.effects = null;
534
543
  /**
535
544
  * @instance
536
545
  * @type {MeetingStateMachine}
@@ -915,14 +924,6 @@ export default class Meeting extends StatelessWebexPlugin {
915
924
  */
916
925
  this.meetingInfoFailureReason = undefined;
917
926
 
918
- /**
919
- * Indicates the current status of BNR in the meeting
920
- * @type {BNR_STATUS}
921
- * @private
922
- * @memberof Meeting
923
- */
924
- this.bnrStatus = BNR_STATUS.NOT_ENABLED;
925
-
926
927
  this.setUpLocusInfoListeners();
927
928
  this.locusInfo.init(attrs.locus ? attrs.locus : {});
928
929
  this.hasJoinedOnce = false;
@@ -1592,6 +1593,24 @@ export default class Meeting extends StatelessWebexPlugin {
1592
1593
  {meetingContainerUrl}
1593
1594
  );
1594
1595
  });
1596
+
1597
+ this.locusInfo.on(LOCUSINFO.EVENTS.CONTROLS_MEETING_TRANSCRIBE_UPDATED,
1598
+ ({caption, transcribing}) => {
1599
+ if (transcribing && this.transcription && this.config.receiveTranscription) {
1600
+ this.receiveTranscription();
1601
+ }
1602
+ else if (!transcribing && this.transcription) {
1603
+ Trigger.trigger(
1604
+ this,
1605
+ {
1606
+ file: 'meeting/index',
1607
+ function: 'setupLocusControlsListener'
1608
+ },
1609
+ EVENT_TRIGGERS.MEETING_STOPPED_RECEIVING_TRANSCRIPTION,
1610
+ {caption, transcribing}
1611
+ );
1612
+ }
1613
+ });
1595
1614
  }
1596
1615
 
1597
1616
  /**
@@ -3396,7 +3415,7 @@ export default class Meeting extends StatelessWebexPlugin {
3396
3415
  * @throws TranscriptionNotSupportedError
3397
3416
  */
3398
3417
  isTranscriptionSupported() {
3399
- if (this.policy?.WEBEX_ASSISTANT_STATUS_ACTIVE) {
3418
+ if (this.locusInfo.controls.transcribe?.transcribing) {
3400
3419
  return true;
3401
3420
  }
3402
3421
 
@@ -4495,7 +4514,9 @@ export default class Meeting extends StatelessWebexPlugin {
4495
4514
  if (!this.canUpdateMedia()) {
4496
4515
  return this.enqueueMediaUpdate(MEDIA_UPDATE_TYPE.AUDIO, options);
4497
4516
  }
4498
- const {sendAudio, receiveAudio, stream} = options;
4517
+ const {
4518
+ sendAudio, receiveAudio, stream, bnrEnabled
4519
+ } = options;
4499
4520
  const {audioTransceiver} = this.mediaProperties.peerConnection;
4500
4521
  let track = MeetingUtil.getTrack(stream).audioTrack;
4501
4522
 
@@ -4503,9 +4524,10 @@ export default class Meeting extends StatelessWebexPlugin {
4503
4524
  return Promise.reject(new ParameterError('Pass sendAudio and receiveAudio parameter'));
4504
4525
  }
4505
4526
 
4506
- if (sendAudio && !this.isAudioMuted() && (this.bnrStatus === BNR_STATUS.ENABLED || this.bnrStatus === BNR_STATUS.SHOULD_ENABLE)) {
4527
+ if (sendAudio && !this.isAudioMuted() && (bnrEnabled === BNR_STATUS.ENABLED || bnrEnabled === BNR_STATUS.SHOULD_ENABLE)) {
4528
+ LoggerProxy.logger.info('Meeting:index#updateAudio. Calling WebRTC enable bnr method');
4507
4529
  track = await this.internal_enableBNR(track);
4508
- this.bnrStatus = BNR_STATUS.ENABLED;
4530
+ LoggerProxy.logger.info('Meeting:index#updateAudio. WebRTC enable bnr request completed');
4509
4531
  }
4510
4532
 
4511
4533
  return MeetingUtil.validateOptions({sendAudio, localStream: stream})
@@ -5771,7 +5793,7 @@ export default class Meeting extends StatelessWebexPlugin {
5771
5793
  * @memberof Meeting
5772
5794
  */
5773
5795
  isBnrEnabled() {
5774
- return this.bnrStatus === BNR_STATUS.ENABLED;
5796
+ return this.effects && this.effects.isBnrEnabled();
5775
5797
  }
5776
5798
 
5777
5799
  /**
@@ -5797,109 +5819,72 @@ export default class Meeting extends StatelessWebexPlugin {
5797
5819
  }
5798
5820
 
5799
5821
  /**
5800
- * enableBNR API
5801
- * @returns {Promise<Boolean>}
5822
+ * Enable the audio track with BNR for a meeting
5823
+ * @returns {Promise} resolves the data from enable bnr or rejects if there is no audio or audio is muted
5802
5824
  * @public
5803
5825
  * @memberof Meeting
5804
5826
  */
5805
- async enableBNR() {
5806
- LoggerProxy.logger.info('Meeting:index#enableBNR. Enable BNR called');
5807
- let isSuccess = false;
5808
-
5809
- try {
5810
- if (typeof this.mediaProperties === 'undefined' || typeof this.mediaProperties.audioTrack === 'undefined') {
5811
- throw new Error("Meeting doesn't have an audioTrack attached");
5812
- }
5813
- else if (this.isAudioMuted()) {
5814
- throw new Error('Cannot enable BNR while meeting is muted');
5815
- }
5827
+ enableBNR() {
5828
+ if (typeof this.mediaProperties === 'undefined' || typeof this.mediaProperties.audioTrack === 'undefined') {
5829
+ return Promise.reject(new Error("Meeting doesn't have an audioTrack attached"));
5830
+ }
5816
5831
 
5832
+ if (this.isAudioMuted()) {
5833
+ return Promise.reject(new Error('Cannot enable BNR while meeting is muted'));
5834
+ }
5817
5835
 
5818
- this.bnrStatus = BNR_STATUS.SHOULD_ENABLE;
5836
+ this.effects = this.effects || createEffectsState('BNR');
5819
5837
 
5820
- const audioStream = MediaUtil.createMediaStream([this.mediaProperties.audioTrack]);
5838
+ const LOG_HEADER = 'Meeting:index#enableBNR -->';
5821
5839
 
5822
- LoggerProxy.logger.info('Meeting:index#enableBNR. MediaStream created from meeting & sent to updateAudio');
5823
- await this.updateAudio({
5824
- sendAudio: true,
5825
- receiveAudio: this.mediaProperties.mediaDirection.receiveAudio,
5826
- stream: audioStream
5827
- });
5828
- this.bnrStatus = BNR_STATUS.ENABLED;
5829
- isSuccess = true;
5830
- Metrics.sendBehavioralMetric(
5831
- BEHAVIORAL_METRICS.ENABLE_BNR_SUCCESS,
5832
- );
5833
- }
5834
- catch (error) {
5835
- this.bnrStatus = BNR_STATUS.NOT_ENABLED;
5836
- Metrics.sendBehavioralMetric(
5837
- BEHAVIORAL_METRICS.ENABLE_BNR_FAILURE,
5838
- {
5839
- reason: error.message,
5840
- stack: error.stack
5841
- }
5842
- );
5843
- LoggerProxy.logger.error('Meeting:index#enableBNR.', error);
5844
- throw error;
5845
- }
5840
+ return logRequest(this.effects.handleClientRequest(true, this)
5841
+ .then((res) => {
5842
+ LoggerProxy.logger.info('Meeting:index#enableBNR. Enable bnr completed');
5846
5843
 
5847
- return isSuccess;
5844
+ return res;
5845
+ })
5846
+ .catch((error) => {
5847
+ throw error;
5848
+ }),
5849
+ {
5850
+ header: `${LOG_HEADER} enable bnr`,
5851
+ success: `${LOG_HEADER} enable bnr success`,
5852
+ failure: `${LOG_HEADER} enable bnr failure, `
5853
+ });
5848
5854
  }
5849
5855
 
5850
5856
  /**
5851
- * disableBNR API
5852
- * @returns {Promise<Boolean>}
5857
+ * Disable the BNR for an audio track
5858
+ * @returns {Promise} resolves the data from disable bnr or rejects if there is no audio set
5853
5859
  * @public
5854
5860
  * @memberof Meeting
5855
5861
  */
5856
- async disableBNR() {
5857
- LoggerProxy.logger.info('Meeting:index#disableBNR. Disable BNR called');
5858
- let isSuccess = false;
5859
-
5860
- try {
5861
- if (!this.isBnrEnabled()) {
5862
- throw new Error('Can not disable as BNR is not enabled');
5863
- }
5864
- else if (typeof this.mediaProperties === 'undefined' || typeof this.mediaProperties.audioTrack === 'undefined') {
5865
- throw new Error("Meeting doesn't have an audioTrack attached");
5866
- }
5867
- const audioTrack = WebRTCMedia.Effects.BNR.disableBNR(this.mediaProperties.audioTrack);
5868
- const audioStream = MediaUtil.createMediaStream([audioTrack]);
5869
-
5870
- LoggerProxy.logger.info('Meeting:index#disableBNR. Raw media track obtained from WebRTC & sent to updateAudio');
5871
-
5872
- this.bnrStatus = BNR_STATUS.SHOULD_DISABLE;
5873
-
5874
- await this.updateAudio({
5875
- sendAudio: true,
5876
- receiveAudio: this.mediaProperties.mediaDirection.receiveAudio,
5877
- stream: audioStream
5878
- });
5879
-
5880
- this.bnrStatus = BNR_STATUS.NOT_ENABLED;
5881
-
5882
- isSuccess = true;
5862
+ disableBNR() {
5863
+ if (typeof this.mediaProperties === 'undefined' || typeof this.mediaProperties.audioTrack === 'undefined') {
5864
+ return Promise.reject(new Error("Meeting doesn't have an audioTrack attached"));
5865
+ }
5883
5866
 
5884
- Metrics.sendBehavioralMetric(
5885
- BEHAVIORAL_METRICS.DISABLE_BNR_SUCCESS
5886
- );
5867
+ if (!this.isBnrEnabled()) {
5868
+ return Promise.reject(new Error('Can not disable as BNR is not enabled'));
5887
5869
  }
5888
- catch (error) {
5889
- this.bnrStatus = BNR_STATUS.ENABLED;
5890
- LoggerProxy.logger.error(`Meeting:index#disableBNR. ${error}`);
5891
5870
 
5892
- Metrics.sendBehavioralMetric(
5893
- BEHAVIORAL_METRICS.DISABLE_BNR_FAILURE,
5894
- {
5895
- reason: error.message,
5896
- stack: error.stack
5897
- }
5898
- );
5871
+ this.effects = this.effects || createEffectsState('BNR');
5899
5872
 
5900
- throw error;
5901
- }
5873
+ const LOG_HEADER = 'Meeting:index#disableBNR -->';
5874
+
5875
+ return logRequest(this.effects.handleClientRequest(false, this)
5876
+ .then((res) => {
5877
+ LoggerProxy.logger.info('Meeting:index#disableBNR. Disable bnr completed');
5902
5878
 
5903
- return isSuccess;
5879
+ return res;
5880
+ })
5881
+ .catch((error) => {
5882
+ throw error;
5883
+ }),
5884
+ {
5885
+ header: `${LOG_HEADER} disable bnr`,
5886
+ success: `${LOG_HEADER} disable bnr success`,
5887
+ failure: `${LOG_HEADER} disable bnr failure, `
5888
+ });
5904
5889
  }
5905
5890
  }
@@ -151,10 +151,7 @@ export default class MeetingInfoV2 {
151
151
  method: HTTP_VERBS.POST,
152
152
  uri,
153
153
  body
154
- })
155
- .catch((err) => {
156
- throw new MeetingInfoV2AdhocMeetingError(err.body?.code, err.body?.message);
157
- });
154
+ });
158
155
  })
159
156
  .catch((err) => {
160
157
  Metrics.sendBehavioralMetric(
@@ -255,6 +255,40 @@ describe('plugin-meetings', () => {
255
255
  });
256
256
  });
257
257
 
258
+ it('should update the transcript state', () => {
259
+ locusInfo.emitScoped = sinon.stub();
260
+ locusInfo.controls = {
261
+ lock: {},
262
+ meetingFull: {},
263
+ record: {
264
+ recording: false,
265
+ paused: true,
266
+ meta: {
267
+ lastModified: 'TODAY',
268
+ modifiedBy: 'George Kittle'
269
+ }
270
+ },
271
+ shareControl: {},
272
+ transcribe: {
273
+ transcribing: false,
274
+ caption: false
275
+ }
276
+ };
277
+ newControls.transcribe.transcribing = true;
278
+ newControls.transcribe.caption = true;
279
+
280
+ locusInfo.updateControls(newControls);
281
+
282
+ assert.calledWith(locusInfo.emitScoped, {
283
+ file: 'locus-info',
284
+ function: 'updateControls'
285
+ },
286
+ LOCUSINFO.EVENTS.CONTROLS_MEETING_TRANSCRIBE_UPDATED,
287
+ {
288
+ transcribing: true, caption: true
289
+ });
290
+ });
291
+
258
292
  it('should update the meetingContainerURL from null', () => {
259
293
  locusInfo.controls = {
260
294
  meetingContainer: {meetingContainerUrl: null},
@@ -0,0 +1,292 @@
1
+ /* eslint-disable camelcase */
2
+ import {assert} from '@webex/test-helper-chai';
3
+ import sinon from 'sinon';
4
+ import MockWebex from '@webex/test-helper-mock-webex';
5
+
6
+ import BEHAVIORAL_METRICS from '@webex/plugin-meetings/src/metrics/constants';
7
+ import {BNR_STATUS} from '@webex/plugin-meetings/src/constants';
8
+ import Meeting from '@webex/plugin-meetings/src/meeting';
9
+ import Meetings from '@webex/plugin-meetings';
10
+ import Metrics from '@webex/plugin-meetings/src/metrics';
11
+ import MediaUtil from '@webex/plugin-meetings/src/media/util';
12
+ import MeetingUtil from '@webex/plugin-meetings/src/meeting/util';
13
+ import createEffectsState from '@webex/plugin-meetings/src/meeting/effectsState';
14
+ import LoggerProxy from '@webex/plugin-meetings/src/common/logs/logger-proxy';
15
+ import LoggerConfig from '@webex/plugin-meetings/src/common/logs/logger-config';
16
+
17
+ describe('plugin-meetings', () => {
18
+ const logger = {
19
+ info: () => {},
20
+ log: () => {},
21
+ error: () => {},
22
+ warn: () => {},
23
+ trace: () => {},
24
+ debug: () => {}
25
+ };
26
+
27
+ beforeEach(() => {
28
+ sinon.stub(Metrics, 'sendBehavioralMetric');
29
+ });
30
+ afterEach(() => {
31
+ sinon.restore();
32
+ });
33
+
34
+ Object.defineProperty(global.window.navigator, 'mediaDevices', {
35
+ writable: true,
36
+ value: {
37
+ getSupportedConstraints: sinon.stub().returns({
38
+ sampleRate: true
39
+ })
40
+ },
41
+ });
42
+ LoggerConfig.set({verboseEvents: true, enable: false});
43
+ LoggerProxy.set(logger);
44
+
45
+ let webex;
46
+ let meeting;
47
+ let uuid1;
48
+
49
+ const fakeMediaTrack = () => ({
50
+ id: Date.now().toString(),
51
+ stop: () => {},
52
+ readyState: 'live',
53
+ enabled: true,
54
+ getSettings: () => ({
55
+ sampleRate: 48000
56
+ })
57
+ });
58
+
59
+ class FakeMediaStream {
60
+ constructor(tracks) {
61
+ this.active = false;
62
+ this.id = '5146425f-c240-48cc-b86b-27d422988fb7';
63
+ this.tracks = tracks;
64
+ }
65
+
66
+ addTrack = () => undefined;
67
+
68
+ getAudioTracks = () => this.tracks;
69
+ }
70
+
71
+ class FakeAudioContext {
72
+ constructor() {
73
+ this.state = 'running';
74
+ this.baseLatency = 0.005333333333333333;
75
+ this.currentTime = 2.7946666666666666;
76
+ this.sampleRate = 48000;
77
+ this.audioWorklet = {
78
+ addModule: async () => undefined,
79
+ };
80
+ }
81
+
82
+ onstatechange = null;
83
+
84
+ createMediaStreamSource() {
85
+ return {
86
+ connect: () => undefined,
87
+ mediaStream: {
88
+ getAudioTracks() {
89
+ // eslint-disable-next-line no-undef
90
+ return [new MediaStreamTrack()];
91
+ },
92
+ },
93
+ };
94
+ }
95
+
96
+ createMediaStreamDestination() {
97
+ return {
98
+ stream: {
99
+ getAudioTracks() {
100
+ // eslint-disable-next-line no-undef
101
+ return [new MediaStreamTrack()];
102
+ },
103
+ },
104
+ };
105
+ }
106
+ }
107
+
108
+ class FakeAudioWorkletNode {
109
+ constructor() {
110
+ this.port = {
111
+ postMessage: () => undefined,
112
+ };
113
+ }
114
+
115
+ connect() {
116
+ /* placeholder method */
117
+ }
118
+ }
119
+
120
+ class FakeMediaStreamTrack {
121
+ constructor() {
122
+ this.kind = 'audio';
123
+ this.enabled = true;
124
+ this.label = 'Default - MacBook Pro Microphone (Built-in)';
125
+ this.muted = false;
126
+ this.readyState = 'live';
127
+ this.contentHint = '';
128
+ }
129
+
130
+ getSettings() {
131
+ return {
132
+ sampleRate: 48000
133
+ };
134
+ }
135
+ }
136
+ Object.defineProperty(global, 'MediaStream', {
137
+ writable: true,
138
+ value: FakeMediaStream,
139
+ });
140
+
141
+ Object.defineProperty(global, 'AudioContext', {
142
+ writable: true,
143
+ value: FakeAudioContext,
144
+ });
145
+
146
+ Object.defineProperty(global, 'AudioWorkletNode', {
147
+ writable: true,
148
+ value: FakeAudioWorkletNode,
149
+ });
150
+
151
+ Object.defineProperty(global, 'MediaStreamTrack', {
152
+ writable: true,
153
+ value: FakeMediaStreamTrack,
154
+ });
155
+
156
+ let effects;
157
+
158
+ beforeEach(() => {
159
+ webex = new MockWebex({
160
+ children: {
161
+ meetings: Meetings
162
+ }
163
+ });
164
+ MediaUtil.createPeerConnection = sinon.stub().returns({});
165
+ meeting = new Meeting(
166
+ {
167
+ userId: uuid1
168
+ },
169
+ {
170
+ parent: webex
171
+ }
172
+ );
173
+
174
+ effects = createEffectsState('BNR');
175
+ meeting.canUpdateMedia = sinon.stub().returns(true);
176
+ MeetingUtil.validateOptions = sinon.stub().returns(Promise.resolve());
177
+ MeetingUtil.updateTransceiver = sinon.stub();
178
+
179
+ meeting.addMedia = sinon.stub().returns(Promise.resolve());
180
+ meeting.getMediaStreams = sinon.stub().returns(Promise.resolve());
181
+ sinon.replace(meeting, 'addMedia', () => {
182
+ sinon.stub(meeting.mediaProperties, 'audioTrack').value(fakeMediaTrack());
183
+ sinon.stub(meeting.mediaProperties, 'mediaDirection').value({
184
+ receiveAudio: true
185
+ });
186
+ });
187
+ });
188
+
189
+ describe('bnr effect library', () => {
190
+ beforeEach(async () => {
191
+ await meeting.getMediaStreams();
192
+ await meeting.addMedia();
193
+ });
194
+ describe('#enableBNR', () => {
195
+ it('should have #enableBnr', () => {
196
+ assert.exists(effects.enableBNR);
197
+ });
198
+
199
+ it('does bnr effect enable on audio track', async () => {
200
+ assert.isTrue(await effects.handleClientRequest(true, meeting));
201
+ assert.equal(effects.state.bnr.enabled, BNR_STATUS.ENABLED);
202
+
203
+ assert(Metrics.sendBehavioralMetric.calledOnce);
204
+ assert.calledWith(
205
+ Metrics.sendBehavioralMetric,
206
+ BEHAVIORAL_METRICS.ENABLE_BNR_SUCCESS,
207
+ );
208
+ });
209
+
210
+ it('does resolve request if bnr is already enabled', async () => {
211
+ effects.state.bnr.enabled = BNR_STATUS.ENABLED;
212
+ assert.isTrue(await effects.handleClientRequest(true, meeting));
213
+ assert.equal(effects.state.bnr.enabled, BNR_STATUS.ENABLED);
214
+ });
215
+
216
+ it('if called twice, does bnr effect enable on audio track for the first request and resolves second', async () => {
217
+ Promise.all([effects.handleClientRequest(true, meeting), effects.handleClientRequest(true, meeting)])
218
+ .then((resolveFirst, resolveSecond) => {
219
+ assert.isTrue(resolveFirst);
220
+ assert.isTrue(resolveSecond);
221
+ assert.calledOnce(MediaUtil.createMediaStream);
222
+ });
223
+ });
224
+
225
+ it('should throw error for inappropriate sample rate and send error metrics', async () => {
226
+ const fakeMediaTrack1 = () => ({
227
+ id: Date.now().toString(),
228
+ stop: () => {},
229
+ readyState: 'live',
230
+ getSettings: () => ({
231
+ sampleRate: 49000
232
+ })
233
+ });
234
+
235
+ sinon.stub(meeting.mediaProperties, 'audioTrack').value(fakeMediaTrack1());
236
+
237
+ // eslint-disable-next-line no-undef
238
+ MediaUtil.createMediaStream = sinon.stub().returns(new MediaStream([fakeMediaTrack1()]));
239
+ try {
240
+ await effects.handleClientRequest(true, meeting);
241
+ }
242
+ catch (err) {
243
+ assert(Metrics.sendBehavioralMetric.calledOnce);
244
+ assert.calledWith(
245
+ Metrics.sendBehavioralMetric,
246
+ BEHAVIORAL_METRICS.ENABLE_BNR_FAILURE, {
247
+ reason: err.message,
248
+ stack: err.stack
249
+ }
250
+ );
251
+ assert.equal(err.message, 'Sample rate of 49000 is not supported.');
252
+ }
253
+ });
254
+ });
255
+
256
+ describe('#disableBNR', () => {
257
+ beforeEach(() => {
258
+ effects.state.bnr.enabled = BNR_STATUS.ENABLED;
259
+ });
260
+ it('should have #disableBnr', () => {
261
+ assert.exists(effects.disableBNR);
262
+ });
263
+
264
+ it('does bnr disable on audio track', async () => {
265
+ assert.isTrue(await effects.handleClientRequest(false, meeting));
266
+ assert.equal(effects.state.bnr.enabled, BNR_STATUS.NOT_ENABLED);
267
+
268
+ assert(Metrics.sendBehavioralMetric.calledOnce);
269
+ assert.calledWith(
270
+ Metrics.sendBehavioralMetric,
271
+ BEHAVIORAL_METRICS.DISABLE_BNR_SUCCESS,
272
+ );
273
+ });
274
+
275
+ it('reject request for disable bnr if not enabled', async () => {
276
+ try {
277
+ await effects.handleClientRequest(false, meeting);
278
+ }
279
+ catch (e) {
280
+ assert.equal(e.message, 'Can not disable as BNR is not enabled');
281
+ assert.equal(effects.state.bnr.enabled, BNR_STATUS.ENABLED);
282
+
283
+ assert(Metrics.sendBehavioralMetric.calledOnce);
284
+ assert.calledWith(
285
+ Metrics.sendBehavioralMetric,
286
+ BEHAVIORAL_METRICS.DISABLE_BNR_FAILURE,
287
+ );
288
+ }
289
+ });
290
+ });
291
+ });
292
+ });