Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ let consoleWarns;
let createCognitiveServicesSpeechServicesPonyfillFactory;
let createSpeechServicesPonyfill;
let originalConsole;
let originalFetch;
let originalWebSocket;

beforeEach(() => {
jest.mock('web-speech-cognitive-services', () => ({
Expand All @@ -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;
Expand Down Expand Up @@ -63,7 +70,8 @@ beforeEach(() => {

afterEach(() => {
console = originalConsole;

window.fetch = originalFetch;
window.WebSocket = originalWebSocket;
jest.resetModules();
});

Expand Down Expand Up @@ -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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,7 +20,8 @@ export default function createCognitiveServicesSpeechServicesPonyfillFactory({
speechRecognitionEndpointId,
speechSynthesisDeploymentId,
speechSynthesisOutputFormat,
textNormalization
textNormalization,
managedCognitiveService
}: {
audioConfig?: AudioConfig;
audioContext?: AudioContext;
Expand All @@ -29,6 +33,7 @@ export default function createCognitiveServicesSpeechServicesPonyfillFactory({
speechSynthesisDeploymentId?: string;
speechSynthesisOutputFormat?: CognitiveServicesAudioOutputFormat;
textNormalization?: CognitiveServicesTextNormalization;
managedCognitiveService?: ManagedCognitiveServiceOptions;
}): WebSpeechPonyfillFactory {
if (!window.navigator.mediaDevices && !audioConfig) {
console.warn(
Expand Down Expand Up @@ -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({
Expand Down
69 changes: 69 additions & 0 deletions packages/bundle/src/createInterceptedFetch.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 14 additions & 0 deletions packages/bundle/src/createInterceptedFetch.ts
Original file line number Diff line number Diff line change
@@ -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;
81 changes: 81 additions & 0 deletions packages/bundle/src/createInterceptedWebsocket.spec.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
15 changes: 15 additions & 0 deletions packages/bundle/src/createInterceptedWebsocket.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions packages/bundle/src/module/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -108,6 +110,8 @@ export {
createAdaptiveCardsAttachmentMiddleware,
createCognitiveServicesSpeechServicesPonyfillFactory,
createDirectLineSpeechAdapters,
createInterceptedFetch,
createInterceptedWebSocket,
createStyleSet,
patchedHooks as hooks,
ReactWebChat,
Expand Down
17 changes: 17 additions & 0 deletions packages/bundle/src/types/ManagedCognitiveServiceOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
type DirectlineAuthorizationToken = {
directlineToken: string;
};

type ManagedCognitiveServicesHostname = {
hostname: string;
};

type ManagedCognitiveServiceBaseOptions = DirectlineAuthorizationToken & ManagedCognitiveServicesHostname;

type ManagedCognitiveServiceOptions =
| ManagedCognitiveServiceBaseOptions
| Promise<ManagedCognitiveServiceBaseOptions>
| (() => ManagedCognitiveServiceBaseOptions)
| (() => Promise<ManagedCognitiveServiceBaseOptions>);

export default ManagedCognitiveServiceOptions;
Loading