@webex/plugin-meetings 3.11.0-next.47 → 3.11.0-next.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -65,12 +65,12 @@
65
65
  "@webex/internal-media-core": "2.23.1",
66
66
  "@webex/internal-plugin-conversation": "3.11.0-next.11",
67
67
  "@webex/internal-plugin-device": "3.11.0-next.8",
68
- "@webex/internal-plugin-llm": "3.11.0-next.11",
68
+ "@webex/internal-plugin-llm": "3.11.0-next.12",
69
69
  "@webex/internal-plugin-mercury": "3.11.0-next.10",
70
70
  "@webex/internal-plugin-metrics": "3.11.0-next.8",
71
71
  "@webex/internal-plugin-support": "3.11.0-next.11",
72
72
  "@webex/internal-plugin-user": "3.11.0-next.8",
73
- "@webex/internal-plugin-voicea": "3.11.0-next.12",
73
+ "@webex/internal-plugin-voicea": "3.11.0-next.13",
74
74
  "@webex/media-helpers": "3.11.0-next.4",
75
75
  "@webex/plugin-people": "3.11.0-next.10",
76
76
  "@webex/plugin-rooms": "3.11.0-next.11",
@@ -84,6 +84,7 @@
84
84
  "global": "^4.4.0",
85
85
  "ip-anonymize": "^0.1.0",
86
86
  "javascript-state-machine": "^3.1.0",
87
+ "jose": "^5.8.0",
87
88
  "jwt-decode": "3.1.2",
88
89
  "lodash": "^4.17.21",
89
90
  "uuid": "^3.3.2",
@@ -93,5 +94,5 @@
93
94
  "//": [
94
95
  "TODO: upgrade jwt-decode when moving to node 18"
95
96
  ],
96
- "version": "3.11.0-next.47"
97
+ "version": "3.11.0-next.48"
97
98
  }
@@ -5,6 +5,7 @@
5
5
  import {Interceptor} from '@webex/http-core';
6
6
  import LoggerProxy from '../common/logs/logger-proxy';
7
7
  import {DATA_CHANNEL_AUTH_HEADER, MAX_RETRY, RETRY_INTERVAL, RETRY_KEY} from './constant';
8
+ import {isJwtTokenExpired} from './utils';
8
9
 
9
10
  /*!
10
11
  * Copyright (c) 2015-2026 Cisco Systems, Inc. See LICENSE file.
@@ -69,6 +70,33 @@ export default class DataChannelAuthTokenInterceptor extends Interceptor {
69
70
  return key ? headers[key] : undefined;
70
71
  }
71
72
 
73
+ /**
74
+ * Intercepts outgoing requests and refreshes the Data-Channel-Auth-Token
75
+ * if the current JWT token is expired before the request is sent.
76
+ *
77
+ * @param {Object} options - The original request options.
78
+ * @returns {Promise<Object>} Updated request options with refreshed token if needed.
79
+ */
80
+ async onRequest(options) {
81
+ const token = this.getHeader(options.headers, DATA_CHANNEL_AUTH_HEADER);
82
+ const enabled = await this._isDataChannelTokenEnabled();
83
+
84
+ if (!token || !enabled) {
85
+ return options;
86
+ }
87
+
88
+ if (isJwtTokenExpired(token)) {
89
+ try {
90
+ const newToken = await this._refreshDataChannelToken();
91
+ options.headers[DATA_CHANNEL_AUTH_HEADER] = newToken;
92
+ } catch (e) {
93
+ LoggerProxy.logger.warn(`DataChannelAuthTokenInterceptor: refresh failed: ${e.message}`);
94
+ }
95
+ }
96
+
97
+ return options;
98
+ }
99
+
72
100
  /**
73
101
  * Intercept responses and, on 401/403 with `Data-Channel-Auth-Token` header,
74
102
  * attempt to refresh the data channel token and retry the original request once.
@@ -0,0 +1,16 @@
1
+ import * as jose from 'jose';
2
+
3
+ const EXPIRY_BUFFER = 30 * 1000;
4
+
5
+ // eslint-disable-next-line import/prefer-default-export
6
+ export const isJwtTokenExpired = (token: string): boolean => {
7
+ try {
8
+ const payload = jose.decodeJwt(token);
9
+
10
+ if (!payload?.exp) return false;
11
+
12
+ return payload.exp * 1000 < Date.now() + EXPIRY_BUFFER;
13
+ } catch {
14
+ return true;
15
+ }
16
+ };
@@ -10317,12 +10317,12 @@ export default class Meeting extends StatelessWebexPlugin {
10317
10317
  } catch (e) {
10318
10318
  const msg = e?.message || String(e);
10319
10319
 
10320
- const err = Object.assign(new Error(`Failed to refresh data channel token: ${msg}`), {
10321
- statusCode: e?.statusCode,
10322
- original: e,
10323
- });
10320
+ LoggerProxy.logger.warn(
10321
+ `Meeting:index#refreshDataChannelToken --> DataChannel token refresh failed (likely locus changed or participant left): ${msg}`,
10322
+ {statusCode: e?.statusCode}
10323
+ );
10324
10324
 
10325
- throw err;
10325
+ return null;
10326
10326
  }
10327
10327
  }
10328
10328
 
@@ -1159,13 +1159,13 @@ export default class MeetingRequest extends StatelessWebexPlugin {
1159
1159
  method: HTTP_VERBS.GET,
1160
1160
  uri,
1161
1161
  }).catch((err) => {
1162
- LoggerProxy.logger.error(
1163
- `Meeting:request#fetchDatachannelToken --> Error retrieving ${
1162
+ LoggerProxy.logger.warn(
1163
+ `Meeting:request#fetchDatachannelToken --> Failed to retrieve ${
1164
1164
  isPracticeSession ? 'practice session ' : ''
1165
- }datachannel token, error ${err}`
1165
+ }datachannel token: ${err?.message || err}`
1166
1166
  );
1167
1167
 
1168
- throw err;
1168
+ return null;
1169
1169
  });
1170
1170
  }
1171
1171
  }
@@ -177,6 +177,8 @@ const Webinar = WebexPlugin.extend({
177
177
 
178
178
  const finalToken = currentToken ?? practiceSessionDatachannelToken;
179
179
 
180
+ const isCaptionBoxOn = this.webex.internal.voicea.getIsCaptionBoxOn();
181
+
180
182
  if (!currentToken && practiceSessionDatachannelToken) {
181
183
  // @ts-ignore
182
184
  this.webex.internal.llm.setDatachannelToken(
@@ -219,6 +221,9 @@ const Webinar = WebexPlugin.extend({
219
221
  );
220
222
  // @ts-ignore - Fix type
221
223
  this.webex.internal.voicea?.announce?.();
224
+ if (isCaptionBoxOn) {
225
+ this.webex.internal.voicea.updateSubchannelSubscriptions({subscribe: ['transcription']});
226
+ }
222
227
  LoggerProxy.logger.info(
223
228
  `Webinar:index#updatePSDataChannel --> enabled to receive relay events for default session for ${LLM_PRACTICE_SESSION}!`
224
229
  );
@@ -5,6 +5,7 @@ import MockWebex from '@webex/test-helper-mock-webex';
5
5
  import {WebexHttpError} from '@webex/webex-core';
6
6
  import DataChannelAuthTokenInterceptor from '@webex/plugin-meetings/src/interceptors/dataChannelAuthToken';
7
7
  import LoggerProxy from '@webex/plugin-meetings/src/common/logs/logger-proxy';
8
+ import * as utils from '@webex/plugin-meetings/src/interceptors/utils';
8
9
  import {DATA_CHANNEL_AUTH_HEADER, MAX_RETRY} from '@webex/plugin-meetings/src/interceptors/constant';
9
10
 
10
11
  describe('plugin-meetings', () => {
@@ -14,6 +15,10 @@ describe('plugin-meetings', () => {
14
15
 
15
16
  beforeEach(() => {
16
17
  clock = sinon.useFakeTimers();
18
+ sinon.stub(LoggerProxy, 'logger').value({
19
+ error: sinon.stub(),
20
+ warn: sinon.stub(),
21
+ });
17
22
 
18
23
  webex = new MockWebex({children: {}});
19
24
  webex.request = sinon.stub().resolves({});
@@ -25,6 +30,7 @@ describe('plugin-meetings', () => {
25
30
  });
26
31
 
27
32
  afterEach(() => {
33
+ sinon.restore();
28
34
  clock.restore();
29
35
  });
30
36
 
@@ -86,6 +92,69 @@ describe('plugin-meetings', () => {
86
92
  });
87
93
  });
88
94
 
95
+ describe('#onRequest', () => {
96
+ let isJwtTokenExpiredStub;
97
+
98
+ beforeEach(() => {
99
+ isJwtTokenExpiredStub = sinon.stub(utils, 'isJwtTokenExpired').returns(false);
100
+ });
101
+
102
+ it('does nothing when token is missing', async () => {
103
+ const options = {headers: {}};
104
+
105
+ const res = await interceptor.onRequest(options);
106
+
107
+ expect(res).to.equal(options);
108
+ sinon.assert.notCalled(isJwtTokenExpiredStub);
109
+ });
110
+
111
+ it('does nothing when feature is disabled', async () => {
112
+ interceptor._isDataChannelTokenEnabled.resolves(false);
113
+
114
+ const options = {headers: {[DATA_CHANNEL_AUTH_HEADER]: 'old-token'}};
115
+ const res = await interceptor.onRequest(options);
116
+
117
+ expect(res).to.equal(options);
118
+ sinon.assert.notCalled(isJwtTokenExpiredStub);
119
+ });
120
+
121
+ it('does not refresh when token is not expired', async () => {
122
+ interceptor._isDataChannelTokenEnabled.resolves(true);
123
+ isJwtTokenExpiredStub.returns(false);
124
+
125
+ const options = {headers: {[DATA_CHANNEL_AUTH_HEADER]: 'old-token'}};
126
+ const res = await interceptor.onRequest(options);
127
+
128
+ sinon.assert.notCalled(interceptor._refreshDataChannelToken);
129
+ expect(res.headers[DATA_CHANNEL_AUTH_HEADER]).to.equal('old-token');
130
+ });
131
+
132
+ it('refreshes token when expired', async () => {
133
+ interceptor._isDataChannelTokenEnabled.resolves(true);
134
+ isJwtTokenExpiredStub.returns(true);
135
+
136
+ interceptor._refreshDataChannelToken.resolves('new-token');
137
+
138
+ const options = {headers: {[DATA_CHANNEL_AUTH_HEADER]: 'old-token'}};
139
+ const res = await interceptor.onRequest(options);
140
+
141
+ sinon.assert.calledOnce(interceptor._refreshDataChannelToken);
142
+ expect(res.headers[DATA_CHANNEL_AUTH_HEADER]).to.equal('new-token');
143
+ });
144
+
145
+ it('continues request when refresh fails', async () => {
146
+ interceptor._isDataChannelTokenEnabled.resolves(true);
147
+ isJwtTokenExpiredStub.returns(true);
148
+
149
+ interceptor._refreshDataChannelToken.rejects(new Error('refresh failed'));
150
+
151
+ const options = {headers: {[DATA_CHANNEL_AUTH_HEADER]: 'old-token'}};
152
+ const res = await interceptor.onRequest(options);
153
+
154
+ expect(res.headers[DATA_CHANNEL_AUTH_HEADER]).to.equal('old-token');
155
+ });
156
+ });
157
+
89
158
  describe('#refreshTokenAndRetryWithDelay', () => {
90
159
  const options = {
91
160
  headers: {[DATA_CHANNEL_AUTH_HEADER]: 'old-token'},
@@ -0,0 +1,75 @@
1
+ import 'jsdom-global/register';
2
+ import {expect} from '@webex/test-helper-chai';
3
+ import sinon from 'sinon';
4
+ import {isJwtTokenExpired} from '@webex/plugin-meetings/src/interceptors/utils';
5
+
6
+ const makeJwt = (payload) =>
7
+ [
8
+ Buffer.from(JSON.stringify({alg: 'none', typ: 'JWT'})).toString('base64url'),
9
+ Buffer.from(JSON.stringify(payload)).toString('base64url'),
10
+ ''
11
+ ].join('.');
12
+
13
+ describe('plugin-meetings', () => {
14
+ describe('Interceptors', () => {
15
+ describe('utils - isJwtTokenExpired', () => {
16
+ let clock;
17
+
18
+ beforeEach(() => {
19
+ clock = sinon.useFakeTimers();
20
+ });
21
+
22
+ afterEach(() => {
23
+ sinon.restore();
24
+ clock.restore();
25
+ });
26
+
27
+ it('returns false when token has no exp', () => {
28
+ const token = makeJwt({}); // no exp
29
+
30
+ const result = isJwtTokenExpired(token);
31
+
32
+ expect(result).to.equal(false);
33
+ });
34
+
35
+ it('returns false when token is not expired', () => {
36
+ const now = Date.now();
37
+ const futureExp = Math.floor((now + 60 * 1000) / 1000);
38
+
39
+ const token = makeJwt({exp: futureExp});
40
+
41
+ const result = isJwtTokenExpired(token);
42
+
43
+ expect(result).to.equal(false);
44
+ });
45
+
46
+ it('returns true when token is expired', () => {
47
+ const now = Date.now();
48
+ const pastExp = Math.floor((now - 60 * 1000) / 1000);
49
+
50
+ const token = makeJwt({exp: pastExp});
51
+
52
+ const result = isJwtTokenExpired(token);
53
+
54
+ expect(result).to.equal(true);
55
+ });
56
+
57
+ it('returns true when token expires within EXPIRY_BUFFER', () => {
58
+ const now = Date.now();
59
+ const expSoon = Math.floor((now + 10 * 1000) / 1000);
60
+
61
+ const token = makeJwt({exp: expSoon});
62
+
63
+ const result = isJwtTokenExpired(token);
64
+
65
+ expect(result).to.equal(true);
66
+ });
67
+
68
+ it('returns true when token is invalid', () => {
69
+ const result = isJwtTokenExpired('not-a-jwt');
70
+
71
+ expect(result).to.equal(true);
72
+ });
73
+ });
74
+ });
75
+ });
@@ -13029,7 +13029,7 @@ describe('plugin-meetings', () => {
13029
13029
  'a datachannel url',
13030
13030
  'token-123'
13031
13031
  );
13032
- assert.calledWithExactly(webex.internal.llm.setDatachannelToken, 'token-123', 'default');
13032
+ assert.calledWithExactly(webex.internal.llm.setDatachannelToken, 'token-123', 'llm-default-session');
13033
13033
  });
13034
13034
  it('prefers refreshed token over locus self token', async () => {
13035
13035
  meeting.joinedWith = {state: 'JOINED'};
@@ -13039,7 +13039,7 @@ describe('plugin-meetings', () => {
13039
13039
  self: {datachannelToken: 'locus-token'},
13040
13040
  };
13041
13041
 
13042
- webex.internal.llm.getDatachannelToken.withArgs('default').returns('refreshed-token');
13042
+ webex.internal.llm.getDatachannelToken.withArgs('llm-default-session').returns('refreshed-token');
13043
13043
 
13044
13044
  await meeting.updateLLMConnection();
13045
13045
 
@@ -13072,7 +13072,7 @@ describe('plugin-meetings', () => {
13072
13072
  'a datachannel url',
13073
13073
  'token-123'
13074
13074
  );
13075
- assert.calledWithExactly(webex.internal.llm.setDatachannelToken, 'token-123', 'default');
13075
+ assert.calledWithExactly(webex.internal.llm.setDatachannelToken, 'token-123', 'llm-default-session');
13076
13076
  });
13077
13077
 
13078
13078
  describe('#clearMeetingData', () => {
@@ -14735,7 +14735,7 @@ describe('plugin-meetings', () => {
14735
14735
  expect(result).to.deep.equal({
14736
14736
  body: {
14737
14737
  datachannelToken: 'mock-token',
14738
- dataChannelTokenType: 'practiceSession',
14738
+ dataChannelTokenType: 'llm-practice-session',
14739
14739
  },
14740
14740
  });
14741
14741
  });
@@ -14748,7 +14748,7 @@ describe('plugin-meetings', () => {
14748
14748
 
14749
14749
  const result = meeting.getDataChannelTokenType();
14750
14750
 
14751
- expect(result).to.equal('practiceSession');
14751
+ expect(result).to.equal('llm-practice-session');
14752
14752
  });
14753
14753
 
14754
14754
  it('returns Default when not in practice session mode', () => {
@@ -14758,7 +14758,7 @@ describe('plugin-meetings', () => {
14758
14758
 
14759
14759
  const result = meeting.getDataChannelTokenType();
14760
14760
 
14761
- expect(result).to.equal('default');
14761
+ expect(result).to.equal('llm-default-session');
14762
14762
  });
14763
14763
  });
14764
14764
  describe('#stopKeepAlive', () => {
@@ -924,7 +924,14 @@ describe('plugin-meetings', () => {
924
924
  const locusUrl = 'https://locus.example.com/locus/api/v1/loci/123';
925
925
  const participantId = 'participant-123';
926
926
 
927
+ beforeEach(() => {
928
+ sinon.restore();
929
+ locusDeltaRequestSpy = sinon.stub(meetingsRequest, 'locusDeltaRequest');
930
+ });
931
+
927
932
  it('sends GET request to regular datachannel token endpoint', async () => {
933
+ locusDeltaRequestSpy.resolves({body: {}});
934
+
928
935
  await meetingsRequest.fetchDatachannelToken({
929
936
  locusUrl,
930
937
  requestingParticipantId: participantId,
@@ -938,6 +945,8 @@ describe('plugin-meetings', () => {
938
945
  });
939
946
 
940
947
  it('sends GET request to practice session datachannel token endpoint', async () => {
948
+ locusDeltaRequestSpy.resolves({body: {}});
949
+
941
950
  await meetingsRequest.fetchDatachannelToken({
942
951
  locusUrl,
943
952
  requestingParticipantId: participantId,
@@ -950,7 +959,7 @@ describe('plugin-meetings', () => {
950
959
  });
951
960
  });
952
961
 
953
- it('throws if locusUrl or participantId is missing', async () => {
962
+ it('rejects when locusUrl or participantId is missing', async () => {
954
963
  await assert.isRejected(
955
964
  meetingsRequest.fetchDatachannelToken({
956
965
  locusUrl: null,
@@ -968,18 +977,15 @@ describe('plugin-meetings', () => {
968
977
  );
969
978
  });
970
979
 
971
- it('logs and rethrows error when locusDeltaRequest fails', async () => {
972
- const error = new Error('network error');
973
- locusDeltaRequestSpy.restore();
974
- sinon.stub(meetingsRequest, 'locusDeltaRequest').rejects(error);
980
+ it('returns null when locusDeltaRequest fails', async () => {
981
+ locusDeltaRequestSpy.rejects(new Error('network error'));
975
982
 
976
- await assert.isRejected(
977
- meetingsRequest.fetchDatachannelToken({
978
- locusUrl,
979
- requestingParticipantId: participantId,
980
- }),
981
- /network error/
982
- );
983
+ const result = await meetingsRequest.fetchDatachannelToken({
984
+ locusUrl,
985
+ requestingParticipantId: participantId,
986
+ });
987
+
988
+ assert.equal(result, null);
983
989
  });
984
990
  });
985
991
  });
@@ -233,6 +233,8 @@ describe('plugin-meetings', () => {
233
233
  // Ensure connect path is eligible
234
234
  webinar.selfIsPanelist = true;
235
235
  webinar.practiceSessionEnabled = true;
236
+ webex.internal.voicea.getIsCaptionBoxOn = sinon.stub().returns(false);
237
+ webex.internal.voicea.updateSubchannelSubscriptions = sinon.stub();
236
238
  });
237
239
 
238
240
  it('no-ops when practice session join eligibility is false', async () => {
@@ -342,6 +344,22 @@ describe('plugin-meetings', () => {
342
344
  processRelayEvent
343
345
  );
344
346
  });
347
+
348
+ it('subscribes to transcription when caption intent is enabled', async () => {
349
+ webex.internal.voicea.getIsCaptionBoxOn = sinon.stub().returns(true);
350
+
351
+ await webinar.updatePSDataChannel();
352
+
353
+ assert.calledOnceWithExactly(webex.internal.voicea.updateSubchannelSubscriptions, { subscribe: ['transcription'] });
354
+ });
355
+
356
+ it('does not subscribe to transcription when caption intent is disabled', async () => {
357
+ webex.internal.voicea.getIsCaptionBoxOn = sinon.stub().returns(false);
358
+
359
+ await webinar.updatePSDataChannel();
360
+
361
+ assert.notCalled(webex.internal.voicea.updateSubchannelSubscriptions);
362
+ });
345
363
  });
346
364
 
347
365
  describe('#updateStatusByRole', () => {