diff --git a/CHANGELOG.md b/CHANGELOG.md index f0cf52355c..3d4c0d8c96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - Added dedicated loading animation for messages in preparing state for Fluent theme, in PR [#5423](https://github.com/microsoft/BotFramework-WebChat/pull/5423), by [@OEvgeny](https://github.com/OEvgeny) - Resolved [#2661](https://github.com/microsoft/BotFramework-WebChat/issues/2661) and [#5352](https://github.com/microsoft/BotFramework-WebChat/issues/5352). Added speech recognition continuous mode with barge-in support, in PR [#5426](https://github.com/microsoft/BotFramework-WebChat/pull/5426), by [@RushikeshGavali](https://github.com/RushikeshGavali) and [@compulim](https://github.com/compulim) - Set `styleOptions.speechRecognitionContinuous` to `true` with a Web Speech API provider with continuous mode support +- Added support for managed cognitive service with reverse proxy in PR [#5444](https://github.com/microsoft/BotFramework-WebChat/pull/5444), by [@prachify](https://github.com/prachify) ### Changed diff --git a/packages/bundle/src/createCognitiveServicesSpeechServicesPonyfillFactory.spec.js b/packages/bundle/src/createCognitiveServicesSpeechServicesPonyfillFactory.spec.js index 30e09f36d7..ff298a8484 100644 --- a/packages/bundle/src/createCognitiveServicesSpeechServicesPonyfillFactory.spec.js +++ b/packages/bundle/src/createCognitiveServicesSpeechServicesPonyfillFactory.spec.js @@ -11,6 +11,8 @@ let consoleWarns; let createCognitiveServicesSpeechServicesPonyfillFactory; let createSpeechServicesPonyfill; let originalConsole; +let originalFetch; +let originalWebSocket; beforeEach(() => { jest.mock('web-speech-cognitive-services', () => ({ @@ -36,6 +38,11 @@ beforeEach(() => { warn: text => consoleWarns.push(text) }; + originalFetch = window.fetch; + originalWebSocket = window.WebSocket; + window.fetch = jest.fn(); + window.WebSocket = jest.fn(); + createSpeechServicesPonyfill = require('web-speech-cognitive-services').createSpeechServicesPonyfill; createCognitiveServicesSpeechServicesPonyfillFactory = require('./createCognitiveServicesSpeechServicesPonyfillFactory').default; @@ -63,7 +70,8 @@ beforeEach(() => { afterEach(() => { console = originalConsole; - + window.fetch = originalFetch; + window.WebSocket = originalWebSocket; jest.resetModules(); }); @@ -180,3 +188,18 @@ test('unsupported environment with audioConfig', () => { expect(consoleWarns).toHaveProperty('length', 0); expect(createSpeechServicesPonyfill.mock.calls[0][0].audioConfig).toBe(audioConfig); }); + +test('should replace window.fetch and window.WebSocket when managedCognitiveService is provided', () => { + const managedCognitiveService = { + hostname: 'example.com', + directlineToken: 'test-token' + }; + + createCognitiveServicesSpeechServicesPonyfillFactory({ + credentials: {}, + managedCognitiveService + }); + + expect(window.fetch).not.toBe(originalFetch); + expect(window.WebSocket).not.toBe(originalWebSocket); +}); diff --git a/packages/bundle/src/createCognitiveServicesSpeechServicesPonyfillFactory.ts b/packages/bundle/src/createCognitiveServicesSpeechServicesPonyfillFactory.ts index 70a93c1523..0909f5bf9d 100644 --- a/packages/bundle/src/createCognitiveServicesSpeechServicesPonyfillFactory.ts +++ b/packages/bundle/src/createCognitiveServicesSpeechServicesPonyfillFactory.ts @@ -6,6 +6,9 @@ import createMicrophoneAudioConfigAndAudioContext from './speech/createMicrophon import CognitiveServicesAudioOutputFormat from './types/CognitiveServicesAudioOutputFormat'; import CognitiveServicesCredentials from './types/CognitiveServicesCredentials'; import CognitiveServicesTextNormalization from './types/CognitiveServicesTextNormalization'; +import ManagedCognitiveServiceOptions from './types/ManagedCognitiveServiceOptions'; +import createInterceptedFetch from './createInterceptedFetch'; +import createInterceptedWebSocket from './createInterceptedWebSocket'; export default function createCognitiveServicesSpeechServicesPonyfillFactory({ audioConfig, @@ -17,7 +20,8 @@ export default function createCognitiveServicesSpeechServicesPonyfillFactory({ speechRecognitionEndpointId, speechSynthesisDeploymentId, speechSynthesisOutputFormat, - textNormalization + textNormalization, + managedCognitiveService }: { audioConfig?: AudioConfig; audioContext?: AudioContext; @@ -29,6 +33,7 @@ export default function createCognitiveServicesSpeechServicesPonyfillFactory({ speechSynthesisDeploymentId?: string; speechSynthesisOutputFormat?: CognitiveServicesAudioOutputFormat; textNormalization?: CognitiveServicesTextNormalization; + managedCognitiveService?: ManagedCognitiveServiceOptions; }): WebSpeechPonyfillFactory { if (!window.navigator.mediaDevices && !audioConfig) { console.warn( @@ -56,6 +61,12 @@ export default function createCognitiveServicesSpeechServicesPonyfillFactory({ })); } + if (managedCognitiveService) { + // If the service is managed, we will use the token provided by the service + window.fetch = createInterceptedFetch(window.fetch, managedCognitiveService); + window.WebSocket = createInterceptedWebSocket(window.WebSocket, managedCognitiveService); + } + return ({ referenceGrammarID } = {}) => { const { SpeechGrammarList, SpeechRecognition, speechSynthesis, SpeechSynthesisUtterance } = createSpeechServicesPonyfill({ diff --git a/packages/bundle/src/createInterceptedFetch.spec.js b/packages/bundle/src/createInterceptedFetch.spec.js new file mode 100644 index 0000000000..36a04da28a --- /dev/null +++ b/packages/bundle/src/createInterceptedFetch.spec.js @@ -0,0 +1,69 @@ +import createInterceptedFetch from './createInterceptedFetch'; + +describe('createInterceptedFetch', () => { + let mockFetch; + + beforeEach(() => { + mockFetch = jest.fn(() => Promise.resolve({ ok: true, status: 200 })); + }); + + it('should return a function', () => { + const interceptedFetch = createInterceptedFetch(mockFetch); + expect(typeof interceptedFetch).toBe('function'); + }); + + it('should call original fetch with the same URL and options if hostname does not match', async () => { + const interceptedFetch = createInterceptedFetch(mockFetch, { + hostname: 'example.com', + directlineToken: 'test-token' + }); + + const url = 'https://other.com/path'; + const options = { headers: {} }; + await interceptedFetch(url, options); + + expect(mockFetch).toHaveBeenCalledWith(url, options); + }); + + it('should modify request headers when hostname matches', async () => { + const interceptedFetch = createInterceptedFetch(mockFetch, { + hostname: 'example.com', + directlineToken: 'test-token' + }); + + const url = 'https://example.com/api/data'; + const options = { headers: {} }; + await interceptedFetch(url, options); + + expect(options.headers['directline_token']).toBe('test-token'); + expect(mockFetch).toHaveBeenCalledWith(url, options); + }); + + it('should preserve existing headers when modifying request', async () => { + const interceptedFetch = createInterceptedFetch(mockFetch, { + hostname: 'example.com', + directlineToken: 'test-token' + }); + + const url = 'https://example.com/api/data'; + const options = { headers: { 'Content-Type': 'application/json' } }; + await interceptedFetch(url, options); + + expect(options.headers['directline_token']).toBe('test-token'); + expect(options.headers['Content-Type']).toBe('application/json'); + expect(mockFetch).toHaveBeenCalledWith(url, options); + }); + + it('should return the response from original fetch', async () => { + const mockResponse = { ok: true, status: 200 }; + mockFetch.mockResolvedValueOnce(mockResponse); + + const interceptedFetch = createInterceptedFetch(mockFetch); + const response = await interceptedFetch('https://test.com', { + hostname: 'example.com', + directlineToken: 'test-token' + }); + + expect(response).toBe(mockResponse); + }); +}); diff --git a/packages/bundle/src/createInterceptedFetch.ts b/packages/bundle/src/createInterceptedFetch.ts new file mode 100644 index 0000000000..751a16041c --- /dev/null +++ b/packages/bundle/src/createInterceptedFetch.ts @@ -0,0 +1,14 @@ +function createInterceptedFetch(originalFetch: typeof window.fetch, fetchOptions?: { [key: string]: any }) { + return async function customFetch(url, options) { + const urlObj = new URL(url); + // Modify request (optional) + if (fetchOptions?.hostname && urlObj.hostname === fetchOptions.hostname) { + options.headers['directline_token'] = fetchOptions.directlineToken; + } + + const response = await originalFetch(url, options); + + return response; + }; +} +export default createInterceptedFetch; diff --git a/packages/bundle/src/createInterceptedWebsocket.spec.js b/packages/bundle/src/createInterceptedWebsocket.spec.js new file mode 100644 index 0000000000..15e2fe629a --- /dev/null +++ b/packages/bundle/src/createInterceptedWebsocket.spec.js @@ -0,0 +1,81 @@ +import createInterceptedWebSocket from './createInterceptedWebSocket'; + +describe('createInterceptedWebSocket', () => { + let mockWebSocket; + + beforeEach(() => { + mockWebSocket = jest.fn().mockImplementation((url, protocols) => ({ + url, + protocols, + send: jest.fn(), + close: jest.fn(), + readyState: 1 + })); + }); + + it('should return a function', () => { + const interceptedWebSocket = createInterceptedWebSocket(mockWebSocket); + expect(typeof interceptedWebSocket).toBe('function'); + }); + + it('should create a WebSocket with the original URL if hostname does not match', () => { + const interceptedWebSocket = createInterceptedWebSocket(mockWebSocket, { + hostname: 'example.com', + directlineToken: 'test-token' + }); + + const url = 'wss://other.com/socket'; + const ws = interceptedWebSocket(url); + + expect(mockWebSocket).toHaveBeenCalledWith(url, undefined); + expect(ws.url).toBe(url); + }); + + it('should modify the WebSocket URL when hostname matches', () => { + const interceptedWebSocket = createInterceptedWebSocket(mockWebSocket, { + hostname: 'example.com', + directlineToken: 'test-token' + }); + + const url = 'wss://example.com/socket'; + const ws = interceptedWebSocket(url); + + expect(ws.url).toContain('directline_token=test-token'); + expect(mockWebSocket).toHaveBeenCalledWith(expect.stringContaining('directline_token=test-token'), undefined); + }); + + it('should preserve protocols when modifying WebSocket URL', () => { + const interceptedWebSocket = createInterceptedWebSocket(mockWebSocket, { + hostname: 'example.com', + directlineToken: 'test-token' + }); + + const url = 'wss://example.com/socket'; + const protocols = ['protocol1', 'protocol2']; + const ws = interceptedWebSocket(url, protocols); + + expect(ws.url).toContain('directline_token=test-token'); + expect(ws.protocols).toEqual(protocols); + expect(mockWebSocket).toHaveBeenCalledWith(expect.stringContaining('directline_token=test-token'), protocols); + }); + + it('should not modify the WebSocket URL if hostname is not provided', () => { + const interceptedWebSocket = createInterceptedWebSocket(mockWebSocket, { + directlineToken: 'test-token' + }); + + const url = 'wss://example.com/socket'; + const ws = interceptedWebSocket(url); + + expect(ws.url).not.toContain('directline_token=test-token'); + expect(mockWebSocket).toHaveBeenCalledWith(url, undefined); + }); + + it('should correctly override the send method', () => { + const interceptedWebSocket = createInterceptedWebSocket(mockWebSocket); + const ws = interceptedWebSocket('wss://test.com'); + ws.send('test message'); + + expect(ws.send).toHaveBeenCalledWith('test message'); + }); +}); diff --git a/packages/bundle/src/createInterceptedWebsocket.ts b/packages/bundle/src/createInterceptedWebsocket.ts new file mode 100644 index 0000000000..5e0dcf3872 --- /dev/null +++ b/packages/bundle/src/createInterceptedWebsocket.ts @@ -0,0 +1,15 @@ +function createInterceptedWebSocket(originalWebSocket: typeof window.WebSocket, fetchOptions?: { [key: string]: any }) { + return function (url: string, protocols?: string | string[]) { + // Modify the request to include custom headers using a WebSocket handshake + const modifiedUrl = new URL(url); + if (fetchOptions?.hostname && modifiedUrl.hostname === fetchOptions.hostname) { + modifiedUrl.searchParams.append('directline_token', fetchOptions.directlineToken); + } + + const ws = new originalWebSocket(modifiedUrl.toString(), protocols); + + return ws; + } as any; +} + +export default createInterceptedWebSocket; diff --git a/packages/bundle/src/module/exports.ts b/packages/bundle/src/module/exports.ts index ea1a5cff41..75053d4f5d 100644 --- a/packages/bundle/src/module/exports.ts +++ b/packages/bundle/src/module/exports.ts @@ -31,6 +31,8 @@ import renderMarkdown from '../markdown/renderMarkdown'; import coreRenderWebChat from '../renderWebChat'; import { type AdaptiveCardsPackage } from '../types/AdaptiveCardsPackage'; import FullBundleStyleOptions, { StrictFullBundleStyleOptions } from '../types/FullBundleStyleOptions'; +import createInterceptedFetch from '../createInterceptedFetch'; +import createInterceptedWebSocket from '../createInterceptedWebSocket'; const renderWebChat = coreRenderWebChat.bind(null, ReactWebChat); @@ -108,6 +110,8 @@ export { createAdaptiveCardsAttachmentMiddleware, createCognitiveServicesSpeechServicesPonyfillFactory, createDirectLineSpeechAdapters, + createInterceptedFetch, + createInterceptedWebSocket, createStyleSet, patchedHooks as hooks, ReactWebChat, diff --git a/packages/bundle/src/types/ManagedCognitiveServiceOptions.ts b/packages/bundle/src/types/ManagedCognitiveServiceOptions.ts new file mode 100644 index 0000000000..d0eb8595b8 --- /dev/null +++ b/packages/bundle/src/types/ManagedCognitiveServiceOptions.ts @@ -0,0 +1,17 @@ +type DirectlineAuthorizationToken = { + directlineToken: string; +}; + +type ManagedCognitiveServicesHostname = { + hostname: string; +}; + +type ManagedCognitiveServiceBaseOptions = DirectlineAuthorizationToken & ManagedCognitiveServicesHostname; + +type ManagedCognitiveServiceOptions = + | ManagedCognitiveServiceBaseOptions + | Promise + | (() => ManagedCognitiveServiceBaseOptions) + | (() => Promise); + +export default ManagedCognitiveServiceOptions;