@webex/internal-plugin-call-ai-summary 3.12.0-next.6 → 3.12.0-next.61

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.
@@ -0,0 +1,388 @@
1
+ /*!
2
+ * Copyright (c) 2015-2025 Cisco Systems, Inc. See LICENSE file.
3
+ */
4
+
5
+ import sinon from 'sinon';
6
+ import {assert} from '@webex/test-helper-chai';
7
+ import MockWebex from '@webex/test-helper-mock-webex';
8
+ import WebexCore from '@webex/webex-core';
9
+ // Importing the package for its side effect is what performs registration.
10
+ import AISummary from '@webex/internal-plugin-call-ai-summary';
11
+
12
+ import config from '../../../src/config';
13
+ import {ERROR_MESSAGES} from '../../../src/constants';
14
+ import {
15
+ actionItemsResponse,
16
+ flattenedContainer,
17
+ notesResponse,
18
+ rawPragyaContainer,
19
+ summaryResponse,
20
+ transcriptResponse,
21
+ } from '../fixture/responses';
22
+
23
+ describe('internal-plugin-call-ai-summary', () => {
24
+ let webex;
25
+ let aisummary;
26
+
27
+ const clone = (value) => JSON.parse(JSON.stringify(value));
28
+
29
+ beforeEach(() => {
30
+ webex = new MockWebex({
31
+ children: {
32
+ aisummary: AISummary,
33
+ },
34
+ });
35
+
36
+ webex.config.aisummary = config.aisummary;
37
+ webex.internal.encryption = {
38
+ decryptText: sinon.stub().callsFake((keyUrl, ciphertext) => Promise.resolve(`decrypted:${ciphertext}`)),
39
+ };
40
+
41
+ aisummary = webex.internal.aisummary;
42
+ });
43
+
44
+ // MOD-007: registration happens as an import side effect. Assert it against the internal-core
45
+ // plugin registry rather than a live WebexCore instance — constructing one boots the service
46
+ // catalog, which fires asynchronous U2C requests that outlive the test and fail the runner.
47
+ describe('registration', () => {
48
+ const registeredPlugins = () =>
49
+ (WebexCore as any).prototype._children.internal.prototype._children;
50
+
51
+ it('registers itself as aisummary on the internal namespace', () => {
52
+ assert.property(
53
+ registeredPlugins(),
54
+ 'aisummary',
55
+ 'importing the package should register the aisummary internal plugin'
56
+ );
57
+ });
58
+
59
+ it('registers the plugin under the AISummary namespace', () => {
60
+ assert.equal(registeredPlugins().aisummary.prototype.namespace, 'AISummary');
61
+ });
62
+
63
+ it('exposes all six public methods on the registered plugin', () => {
64
+ const {prototype} = registeredPlugins().aisummary;
65
+
66
+ [
67
+ 'getContainer',
68
+ 'getSummary',
69
+ 'getNotes',
70
+ 'getActionItems',
71
+ 'getTranscriptUrl',
72
+ 'getTranscript',
73
+ ].forEach((method) => {
74
+ assert.isFunction(prototype[method], `${method} should be registered`);
75
+ });
76
+ });
77
+ });
78
+
79
+ describe('#getContainer', () => {
80
+ it('requests the container through the pragya service catalog entry', async () => {
81
+ webex.request = sinon.stub().resolves({body: clone(rawPragyaContainer)});
82
+
83
+ await aisummary.getContainer({containerId: 'container-123'});
84
+
85
+ assert.calledOnceWithExactly(webex.request, {
86
+ method: 'GET',
87
+ service: 'pragya',
88
+ resource: 'containers/container-123',
89
+ });
90
+ });
91
+
92
+ it('flattens summaryData.data onto summaryData', async () => {
93
+ webex.request = sinon.stub().resolves({body: clone(rawPragyaContainer)});
94
+
95
+ const result = await aisummary.getContainer({containerId: 'container-123'});
96
+
97
+ assert.equal(result.summaryData.status, 'Active');
98
+ assert.equal(result.summaryData.summaryUrl, 'https://aibridge-url/summaries/c635e870');
99
+ assert.isUndefined(result.summaryData.data);
100
+ });
101
+
102
+ it('leaves summaryData untouched when the upstream body is already flat', async () => {
103
+ webex.request = sinon.stub().resolves({body: clone(flattenedContainer)});
104
+
105
+ const result = await aisummary.getContainer({containerId: 'container-123'});
106
+
107
+ assert.equal(result.summaryData.summaryUrl, flattenedContainer.summaryData.summaryUrl);
108
+ });
109
+
110
+ // Validation runs before any request is issued, and getContainer is not async,
111
+ // so an invalid id throws synchronously rather than rejecting.
112
+ [
113
+ {name: 'an empty string', containerId: ''},
114
+ {name: 'whitespace only', containerId: ' '},
115
+ {name: 'undefined', containerId: undefined},
116
+ {name: 'a non-string', containerId: 42},
117
+ ].forEach(({name, containerId}) => {
118
+ it(`throws synchronously when containerId is ${name}`, () => {
119
+ webex.request = sinon.stub();
120
+
121
+ assert.throws(
122
+ () => aisummary.getContainer({containerId}),
123
+ ERROR_MESSAGES.INVALID_CONTAINER_ID
124
+ );
125
+ assert.notCalled(webex.request);
126
+ });
127
+ });
128
+ });
129
+
130
+ describe('#getSummary', () => {
131
+ it('requests the summary url with the fields query', async () => {
132
+ webex.request = sinon.stub().resolves({body: clone(summaryResponse)});
133
+
134
+ await aisummary.getSummary({containerInfo: flattenedContainer});
135
+
136
+ assert.calledOnceWithExactly(webex.request, {
137
+ method: 'GET',
138
+ uri: `${flattenedContainer.summaryData.summaryUrl}?fields=note,shortnote,actionitems`,
139
+ });
140
+ });
141
+
142
+ it('decrypts the note, short note and action item snippets', async () => {
143
+ webex.request = sinon.stub().resolves({body: clone(summaryResponse)});
144
+
145
+ const result = await aisummary.getSummary({containerInfo: flattenedContainer});
146
+
147
+ assert.equal(result.id, summaryResponse.id);
148
+ assert.equal(result.note, 'decrypted:<encrypted_note_content>');
149
+ assert.equal(result.shortNote, 'decrypted:<encrypted_short_note_content>');
150
+ assert.lengthOf(result.actionItems, 1);
151
+ assert.equal(
152
+ result.actionItems[0].aiGeneratedContent,
153
+ 'decrypted:<encrypted_ai_generated_content>'
154
+ );
155
+ assert.equal(result.actionItems[0].editedContent, 'edited version');
156
+ });
157
+
158
+ it('extracts feedbackUrl from the links array', async () => {
159
+ webex.request = sinon.stub().resolves({body: clone(summaryResponse)});
160
+
161
+ const result = await aisummary.getSummary({containerInfo: flattenedContainer});
162
+
163
+ assert.equal(result.feedbackUrl, summaryResponse.links[0].href);
164
+ });
165
+
166
+ it('returns an undefined feedbackUrl when no feedback link is present', async () => {
167
+ const body = clone(summaryResponse);
168
+
169
+ body.links = [];
170
+ webex.request = sinon.stub().resolves({body});
171
+
172
+ const result = await aisummary.getSummary({containerInfo: flattenedContainer});
173
+
174
+ assert.isUndefined(result.feedbackUrl);
175
+ });
176
+
177
+ it('prefers the response keyUrl over containerInfo.encryptionKeyUrl', async () => {
178
+ webex.request = sinon.stub().resolves({body: clone(summaryResponse)});
179
+
180
+ await aisummary.getSummary({containerInfo: flattenedContainer});
181
+
182
+ assert.calledWith(
183
+ webex.internal.encryption.decryptText,
184
+ summaryResponse.keyUrl,
185
+ '<encrypted_note_content>'
186
+ );
187
+ });
188
+
189
+ it('falls back to containerInfo.encryptionKeyUrl when the response omits keyUrl', async () => {
190
+ const body = clone(summaryResponse);
191
+
192
+ delete body.keyUrl;
193
+ webex.request = sinon.stub().resolves({body});
194
+
195
+ await aisummary.getSummary({containerInfo: flattenedContainer});
196
+
197
+ assert.calledWith(
198
+ webex.internal.encryption.decryptText,
199
+ flattenedContainer.encryptionKeyUrl,
200
+ '<encrypted_note_content>'
201
+ );
202
+ });
203
+ });
204
+
205
+ describe('#getNotes', () => {
206
+ it('fetches and decrypts notes from notesUrl', async () => {
207
+ webex.request = sinon.stub().resolves({body: clone(notesResponse)});
208
+
209
+ const result = await aisummary.getNotes({containerInfo: flattenedContainer});
210
+
211
+ assert.calledOnceWithExactly(webex.request, {
212
+ method: 'GET',
213
+ uri: flattenedContainer.summaryData.notesUrl,
214
+ });
215
+ assert.equal(result.id, notesResponse.id);
216
+ assert.equal(result.content, 'decrypted:<encrypted_content>');
217
+ assert.equal(result.feedbackUrl, notesResponse.feedbackUrl);
218
+ });
219
+ });
220
+
221
+ describe('#getActionItems', () => {
222
+ it('unwraps the array response and decrypts every snippet', async () => {
223
+ webex.request = sinon.stub().resolves({body: clone(actionItemsResponse)});
224
+
225
+ const result = await aisummary.getActionItems({containerInfo: flattenedContainer});
226
+
227
+ assert.equal(result.id, actionItemsResponse[0].id);
228
+ assert.lengthOf(result.snippets, 1);
229
+ assert.equal(
230
+ result.snippets[0].aiGeneratedContent,
231
+ 'decrypted:<encrypted_ai_generated_content>'
232
+ );
233
+ assert.equal(result.snippets[0].editedContent, 'edited version');
234
+ });
235
+
236
+ it('returns an empty snippet list when the response array is empty', async () => {
237
+ webex.request = sinon.stub().resolves({body: []});
238
+
239
+ const result = await aisummary.getActionItems({containerInfo: flattenedContainer});
240
+
241
+ assert.isUndefined(result.id);
242
+ assert.deepEqual(result.snippets, []);
243
+ assert.notCalled(webex.internal.encryption.decryptText);
244
+ });
245
+
246
+ it('omits editedContent when the snippet has no edited version', async () => {
247
+ const body = clone(actionItemsResponse);
248
+
249
+ delete body[0].snippets[0].content;
250
+ webex.request = sinon.stub().resolves({body});
251
+
252
+ const result = await aisummary.getActionItems({containerInfo: flattenedContainer});
253
+
254
+ assert.isUndefined(result.snippets[0].editedContent);
255
+ });
256
+ });
257
+
258
+ describe('#getTranscriptUrl', () => {
259
+ it('returns the transcript url synchronously without a request', () => {
260
+ webex.request = sinon.stub();
261
+
262
+ const url = aisummary.getTranscriptUrl({containerInfo: flattenedContainer});
263
+
264
+ assert.equal(url, flattenedContainer.summaryData.transcriptUrl);
265
+ assert.notCalled(webex.request);
266
+ assert.notCalled(webex.internal.encryption.decryptText);
267
+ });
268
+ });
269
+
270
+ describe('#getTranscript', () => {
271
+ it('fetches and decrypts every transcript snippet', async () => {
272
+ webex.request = sinon.stub().resolves({body: clone(transcriptResponse)});
273
+
274
+ const result = await aisummary.getTranscript({containerInfo: flattenedContainer});
275
+
276
+ assert.equal(result.id, transcriptResponse.id);
277
+ assert.equal(result.totalCount, 2);
278
+ assert.lengthOf(result.snippets, 2);
279
+ assert.equal(result.snippets[0].content, 'decrypted:<encrypted_snippet_1>');
280
+ assert.equal(result.snippets[1].content, 'decrypted:<encrypted_snippet_2>');
281
+ assert.equal(result.snippets[0].startTime, '1000');
282
+ assert.equal(result.snippets[0].endTime, '2000');
283
+ assert.equal(result.snippets[0].audioCSI, 'csi-1');
284
+ assert.deepEqual(result.snippets[0].speaker, {
285
+ speakerName: 'Ada Lovelace',
286
+ speakerId: 'speaker-1',
287
+ });
288
+ });
289
+
290
+ it('returns an empty snippet list when the transcript has no snippets', async () => {
291
+ webex.request = sinon.stub().resolves({body: {id: 'transcript-id', totalCount: 0}});
292
+
293
+ const result = await aisummary.getTranscript({containerInfo: flattenedContainer});
294
+
295
+ assert.deepEqual(result.snippets, []);
296
+ });
297
+ });
298
+
299
+ describe('containerInfo validation', () => {
300
+ // Every content method validates its own summaryData url field plus the
301
+ // encryption key before issuing a request.
302
+ [
303
+ {method: 'getSummary', urlField: 'summaryUrl'},
304
+ {method: 'getNotes', urlField: 'notesUrl'},
305
+ {method: 'getActionItems', urlField: 'actionItemsUrl'},
306
+ {method: 'getTranscript', urlField: 'transcriptUrl'},
307
+ ].forEach(({method, urlField}) => {
308
+ it(`${method} rejects when ${urlField} is missing`, async () => {
309
+ const containerInfo = clone(flattenedContainer);
310
+
311
+ delete containerInfo.summaryData[urlField];
312
+ webex.request = sinon.stub();
313
+
314
+ await assert.isRejected(
315
+ aisummary[method]({containerInfo}),
316
+ ERROR_MESSAGES.INVALID_CONTAINER_INFO
317
+ );
318
+ assert.notCalled(webex.request);
319
+ });
320
+
321
+ it(`${method} rejects when encryptionKeyUrl is missing`, async () => {
322
+ const containerInfo = clone(flattenedContainer);
323
+
324
+ delete containerInfo.encryptionKeyUrl;
325
+ webex.request = sinon.stub();
326
+
327
+ await assert.isRejected(
328
+ aisummary[method]({containerInfo}),
329
+ ERROR_MESSAGES.INVALID_CONTAINER_INFO
330
+ );
331
+ assert.notCalled(webex.request);
332
+ });
333
+ });
334
+
335
+ it('getTranscriptUrl throws synchronously when transcriptUrl is missing', () => {
336
+ const containerInfo = clone(flattenedContainer);
337
+
338
+ delete containerInfo.summaryData.transcriptUrl;
339
+
340
+ assert.throws(
341
+ () => aisummary.getTranscriptUrl({containerInfo}),
342
+ ERROR_MESSAGES.INVALID_CONTAINER_INFO
343
+ );
344
+ });
345
+ });
346
+
347
+ describe('error normalization', () => {
348
+ [
349
+ {statusCode: 401, expected: ERROR_MESSAGES.AUTHENTICATION_FAILED},
350
+ {statusCode: 403, expected: ERROR_MESSAGES.ACCESS_DENIED},
351
+ {statusCode: 404, expected: ERROR_MESSAGES.CONTAINER_NOT_FOUND},
352
+ ].forEach(({statusCode, expected}) => {
353
+ it(`maps a ${statusCode} from getContainer to "${expected}"`, async () => {
354
+ webex.request = sinon.stub().rejects(Object.assign(new Error('upstream'), {statusCode}));
355
+
356
+ await assert.isRejected(aisummary.getContainer({containerId: 'c1'}), expected);
357
+ });
358
+ });
359
+
360
+ it('maps a 404 from a content endpoint to the content-not-found message', async () => {
361
+ webex.request = sinon.stub().rejects(Object.assign(new Error('upstream'), {statusCode: 404}));
362
+
363
+ await assert.isRejected(
364
+ aisummary.getNotes({containerInfo: flattenedContainer}),
365
+ ERROR_MESSAGES.CONTENT_NOT_FOUND
366
+ );
367
+ });
368
+
369
+ it('prefixes the method name for an unmapped failure', async () => {
370
+ webex.request = sinon.stub().rejects(new Error('socket hang up'));
371
+
372
+ await assert.isRejected(
373
+ aisummary.getNotes({containerInfo: flattenedContainer}),
374
+ /getNotes failed: socket hang up/
375
+ );
376
+ });
377
+
378
+ it('propagates a decryption failure through error normalization', async () => {
379
+ webex.request = sinon.stub().resolves({body: clone(notesResponse)});
380
+ webex.internal.encryption.decryptText = sinon.stub().rejects(new Error('kms unavailable'));
381
+
382
+ await assert.isRejected(
383
+ aisummary.getNotes({containerInfo: flattenedContainer}),
384
+ /getNotes failed: kms unavailable/
385
+ );
386
+ });
387
+ });
388
+ });
package/ai-docs/AGENTS.md DELETED
@@ -1,300 +0,0 @@
1
- # @webex/internal-plugin-call-ai-summary
2
-
3
- This is an internal Cisco Webex plugin. As such, it does not strictly adhere to semantic versioning. Use at your own risk. If you're not working on one of our first party clients, please look at our developer api and stick to our public plugins.
4
- Internal Webex JS SDK plugin for retrieving AI-generated call summaries, notes, action items, and transcript URLs from the Pragya and AI Bridge services.
5
-
6
- ## Overview
7
-
8
- This plugin provides methods to:
9
-
10
- 1. Resolve a **Pragya container** by ID (returns metadata, summary URLs, and encryption key)
11
- 2. Fetch and decrypt **AI-generated summaries** (note, short note, action items) in a single call
12
- 3. Fetch and decrypt **AI-generated notes** via a dedicated notes endpoint
13
- 4. Fetch and decrypt **AI-generated action items** via a dedicated action items endpoint
14
- 5. Retrieve the **transcript URL** for a call
15
-
16
- All AI-generated content is **JWE-encrypted** and decrypted via the KMS (Key Management Service) using `@webex/internal-plugin-encryption`.
17
-
18
- ## Architecture
19
-
20
- ```
21
- Pragya Service AI Bridge Service
22
- (container metadata) (summary content)
23
- | |
24
- getContainer() getSummary() / getNotes() / getActionItems()
25
- | |
26
- v v
27
- PragyaContainerResponse Encrypted JWE content
28
- (summaryData, encryptionKeyUrl) |
29
- v
30
- KMS Decryption
31
- (internal-plugin-encryption)
32
- |
33
- v
34
- Decrypted plaintext (HTML)
35
- ```
36
-
37
- **Note:** The Pragya API returns summary URLs nested under `summaryData.data`. The `getContainer()` method normalizes this automatically, flattening `summaryData.data` into `summaryData` so consumers can access `summaryData.summaryUrl` directly.
38
-
39
- ## Registration
40
-
41
- The plugin registers itself as `aisummary` on the internal namespace:
42
-
43
- ```typescript
44
- import '@webex/internal-plugin-call-ai-summary';
45
-
46
- // Accessed via:
47
- webex.internal.aisummary.getContainer({ containerId: '...' });
48
- ```
49
-
50
- ## Source Files
51
-
52
- | File | Description |
53
- |------|-------------|
54
- | `src/index.ts` | Entry point. Registers the plugin via `registerInternalPlugin('aisummary', ...)`. |
55
- | `src/ai-summary.ts` | Main plugin class extending `WebexPlugin`. Contains all public and private methods. |
56
- | `src/types.ts` | TypeScript interfaces for request/response DTOs. |
57
- | `src/constants.ts` | Service name, resource path, and error message constants. |
58
- | `src/config.ts` | Plugin configuration (currently empty). |
59
-
60
- ## API Reference
61
-
62
- ### `getContainer(options: GetContainerOptions): Promise<PragyaContainerResponse>`
63
-
64
- Resolves a Pragya container by ID. Returns container metadata including summary URLs and the KMS encryption key URL. Normalizes the response by flattening `summaryData.data` into `summaryData`.
65
-
66
- ```typescript
67
- const container = await webex.internal.aisummary.getContainer({
68
- containerId: '34125120-13b5-11f1-9b36-adb685725098',
69
- });
70
-
71
- // After normalization, URLs are directly on summaryData:
72
- console.log(container.summaryData.summaryUrl); // https://aibridge-.../summaries/...
73
- console.log(container.summaryData.transcriptUrl); // https://aibridge-.../transcripts/...
74
- ```
75
-
76
- **Request**: `GET {pragya-service}/containers/{containerId}`
77
-
78
- **Response fields**:
79
- - `summaryData` — Contains summary URLs (`summaryUrl`, `transcriptUrl`, `status`, `summarizeAfterCall`)
80
- - `encryptionKeyUrl` — KMS key URL for decrypting content (e.g., `kms://kms-aore.wbx2.com/keys/...`)
81
- - `kmsResourceObjectUrl`, `aclUrl`, `forkSessionId`, `callSessionId`, `ownerUserId`, `orgId`, `start`, `end`
82
-
83
- ### `getSummary(options: GetSummaryContentOptions): Promise<SummaryContent>`
84
-
85
- Fetches all AI-generated summary content (note, short note, and action items) from a single request to the summary URL, and decrypts each field via KMS. This is the primary method for retrieving summary content.
86
-
87
- ```typescript
88
- const summary = await webex.internal.aisummary.getSummary({
89
- containerInfo: container,
90
- });
91
-
92
- console.log(summary.note); // Decrypted full note (HTML)
93
- console.log(summary.shortNote); // Decrypted short note (HTML)
94
- console.log(summary.actionItems); // Array of decrypted action item snippets
95
- console.log(summary.feedbackUrl); // Feedback URL from links (if available)
96
- ```
97
-
98
- **Request**: `GET {summaryUrl}?fields=note,shortnote,actionitems`
99
-
100
- **Response structure** (from AI Bridge, before decryption):
101
- ```json
102
- {
103
- "id": "...",
104
- "keyUrl": "kms://...",
105
- "note": { "aiGeneratedContent": "<JWE>" },
106
- "shortnote": { "aiGeneratedContent": "<JWE>" },
107
- "actionitems": {
108
- "snippets": [
109
- { "id": "...", "aiGeneratedContent": "<JWE>" }
110
- ]
111
- },
112
- "links": [
113
- { "rel": "feedback", "href": "https://..." }
114
- ]
115
- }
116
- ```
117
-
118
- **Return type** (`SummaryContent`):
119
- - `id` — Summary identifier
120
- - `note` — Decrypted full note (HTML string)
121
- - `shortNote` — Decrypted short note (HTML string)
122
- - `actionItems` — Array of `ActionItemSnippet` objects
123
- - `feedbackUrl` — Extracted from `links` array (`rel: "feedback"`), if available
124
-
125
- ### `getNotes(options: GetSummaryContentOptions): Promise<SummaryNotes>`
126
-
127
- Fetches AI-generated notes from the dedicated notes endpoint and decrypts via KMS. Requires `notesUrl` to be present in the container's `summaryData`.
128
-
129
- ```typescript
130
- const notes = await webex.internal.aisummary.getNotes({
131
- containerInfo: container,
132
- });
133
-
134
- console.log(notes.content); // Decrypted notes content
135
- ```
136
-
137
- **Request**: `GET {notesUrl}`
138
-
139
- > **Note:** The `notesUrl` may not be present in all API versions. Prefer `getSummary()` which returns notes, short notes, and action items in a single call.
140
-
141
- ### `getActionItems(options: GetSummaryContentOptions): Promise<SummaryActionItems>`
142
-
143
- Fetches AI-generated action items from the dedicated action items endpoint and decrypts each snippet via KMS. Requires `actionItemsUrl` to be present in the container's `summaryData`.
144
-
145
- ```typescript
146
- const actionItems = await webex.internal.aisummary.getActionItems({
147
- containerInfo: container,
148
- });
149
-
150
- actionItems.snippets.forEach((item) => {
151
- console.log(item.aiGeneratedContent); // Decrypted action item
152
- });
153
- ```
154
-
155
- **Request**: `GET {actionItemsUrl}`
156
-
157
- > **Note:** The `actionItemsUrl` may not be present in all API versions. Prefer `getSummary()` which returns notes, short notes, and action items in a single call.
158
-
159
- ### `getTranscriptUrl(options: GetSummaryContentOptions): string`
160
-
161
- Returns the transcript URL from the container info. Does not fetch or decrypt content.
162
-
163
- ```typescript
164
- const transcriptUrl = webex.internal.aisummary.getTranscriptUrl({
165
- containerInfo: container,
166
- });
167
- ```
168
-
169
- ## Types
170
-
171
- ### Request Types
172
-
173
- ```typescript
174
- interface GetContainerOptions {
175
- containerId: string; // Pragya container ID
176
- }
177
-
178
- interface GetSummaryContentOptions {
179
- containerInfo: PragyaContainerResponse; // Resolved container from getContainer()
180
- }
181
- ```
182
-
183
- ### Response Types
184
-
185
- ```typescript
186
- interface PragyaContainerResponse {
187
- summaryData: PragyaSummaryData;
188
- encryptionKeyUrl: string;
189
- kmsResourceObjectUrl: string;
190
- aclUrl: string;
191
- forkSessionId: string;
192
- callSessionId: string;
193
- ownerUserId: string;
194
- orgId: string;
195
- start: string;
196
- end: string;
197
- }
198
-
199
- interface PragyaSummaryData {
200
- status: string;
201
- summaryUrl: string;
202
- transcriptUrl: string;
203
- summarizeAfterCall: boolean;
204
- notesUrl?: string; // May not be present in all API versions
205
- actionItemsUrl?: string; // May not be present in all API versions
206
- }
207
-
208
- interface SummaryContent {
209
- id: string;
210
- note: string; // Decrypted full note (HTML)
211
- shortNote: string; // Decrypted short note (HTML)
212
- actionItems: ActionItemSnippet[];
213
- feedbackUrl?: string; // From links array (rel="feedback")
214
- }
215
-
216
- interface SummaryNotes {
217
- id: string;
218
- content: string; // Decrypted notes content
219
- feedbackUrl?: string;
220
- }
221
-
222
- interface SummaryActionItems {
223
- id: string;
224
- snippets: ActionItemSnippet[];
225
- feedbackUrl?: string;
226
- }
227
-
228
- interface ActionItemSnippet {
229
- id: string;
230
- editedContent?: string; // User-edited version (if available)
231
- aiGeneratedContent: string; // Decrypted AI-generated content
232
- }
233
- ```
234
-
235
- ## Error Handling
236
-
237
- The plugin normalizes HTTP errors into descriptive messages:
238
-
239
- | Status Code | Error Message |
240
- |-------------|---------------|
241
- | 401 | `Authentication failed: Invalid or expired token` |
242
- | 403 | `Access denied: User not authorized to view this summary` |
243
- | 404 | `Container not found` |
244
- | Other | `{methodName} failed: {error.message}` |
245
-
246
- Validation errors are thrown synchronously:
247
- - Missing or empty `containerId` throws `containerId is required and must be a non-empty string`
248
- - Missing `containerInfo`, `summaryData` URL, or `encryptionKeyUrl` throws `containerInfo with valid summaryData and encryptionKeyUrl is required`
249
-
250
- ## Encryption / Decryption
251
-
252
- All AI-generated content from the AI Bridge service is JWE-encrypted. Decryption uses:
253
-
254
- ```
255
- webex.internal.encryption.decryptText(encryptionKeyUrl, encryptedContent)
256
- ```
257
-
258
- This requires:
259
- 1. A registered device (`webex.internal.device.register()`)
260
- 2. Mercury WebSocket connection (initiated automatically during KMS key fetch)
261
- 3. ECDHE key exchange with KMS
262
- 4. Key retrieval from KMS using the `encryptionKeyUrl`
263
-
264
- The SDK handles steps 1-4 automatically when `decryptText` is called.
265
-
266
- ## Dependencies
267
-
268
- - `@webex/webex-core` — Base plugin class, request handling, auth interceptor
269
- - `@webex/internal-plugin-encryption` — KMS decryption
270
-
271
- ## Token Requirements
272
-
273
- The Pragya and AI Bridge APIs require a valid Webex access token. The SDK's auth interceptor automatically attaches the token for URLs in the service catalog or on allowed domains (e.g., `wbx2.com`, `webex.com`).
274
-
275
- ## Manual Testing
276
-
277
- Two manual test scripts are provided in `src/`:
278
-
279
- ### `manual-pragya-api-test.js`
280
- Validates the Pragya container response structure (34 checks).
281
-
282
- ```bash
283
- cd packages/@webex/internal-plugin-call-ai-summary
284
- WEBEX_TOKEN='<token>' node src/manual-pragya-api-test.js
285
- ```
286
-
287
- ### `manual-integration-test.js`
288
- Tests the full end-to-end flow using the SDK service catalog:
289
- 1. Device registration (WDM) to populate the service catalog
290
- 2. `getContainer` via plugin (resolves `service: 'pragya'` from the catalog)
291
- 3. `getSummary` via plugin (fetches + decrypts note, short note, and action items via KMS)
292
- 4. `getTranscriptUrl` via plugin
293
- 5. Transcript content fetch
294
-
295
- ```bash
296
- cd packages/@webex/internal-plugin-call-ai-summary
297
- WEBEX_TOKEN='<token>' CONTAINER_ID='<id>' node src/manual-integration-test.js
298
- ```
299
-
300
- Both scripts require a valid Webex access token. Set `WEBEX_TOKEN` and optionally `CONTAINER_ID` as environment variables, or update the placeholder values in the scripts.