Skip to content

Commit

Permalink
Migrated fron Bing Speech to Cognitive Services Speech API.
Browse files Browse the repository at this point in the history
  • Loading branch information
edwin4microsoft authored and tonyanziano committed Oct 8, 2019
1 parent 98e7b21 commit 90384b8
Show file tree
Hide file tree
Showing 11 changed files with 186 additions and 133 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -69,6 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- [client] Fixed an issue with the transcripts path input inside of the resource settings dialog in PR [1836](https://github.com/microsoft/BotFramework-Emulator/pull/1836)
- [client] Implemented HTML app menu for Windows in PR [1893](https://github.com/microsoft/BotFramework-Emulator/pull/1893)
- [client/main] Migrated from Bing Speech API to Cognitive Services Speech API in PR [1878](https://github.com/microsoft/BotFramework-Emulator/pull/1878)


## v4.5.2 - 2019 - 07 - 17
Expand Down
2 changes: 1 addition & 1 deletion packages/app/client/src/state/sagas/chatSagas.spec.ts
Expand Up @@ -88,7 +88,7 @@ jest.mock('electron', () => {

jest.mock('botframework-webchat', () => {
return {
createCognitiveServicesBingSpeechPonyfillFactory: () => () => 'Yay! ponyfill!',
createCognitiveServicesSpeechServicesPonyfillFactory: () => () => 'Yay! ponyfill!',
};
});

Expand Down
21 changes: 11 additions & 10 deletions packages/app/client/src/state/sagas/chatSagas.ts
Expand Up @@ -36,7 +36,7 @@ import { Activity } from 'botframework-schema';
import { SharedConstants, ValueTypes, newNotification } from '@bfemulator/app-shared';
import { CommandServiceImpl, CommandServiceInstance, ConversationService } from '@bfemulator/sdk-shared';
import { IEndpointService } from 'botframework-config/lib/schema';
import { createCognitiveServicesBingSpeechPonyfillFactory } from 'botframework-webchat';
import { createCognitiveServicesSpeechServicesPonyfillFactory } from 'botframework-webchat';
import { createStore as createWebChatStore } from 'botframework-webchat-core';
import { call, ForkEffect, put, select, takeEvery, takeLatest } from 'redux-saga/effects';

Expand Down Expand Up @@ -157,23 +157,24 @@ export class ChatSagas {
// If an existing factory is found, refresh the token
const existingFactory: string = yield select(getWebSpeechFactoryForDocumentId, documentId);
const { GetSpeechToken: command } = SharedConstants.Commands.Emulator;
let token;

try {
token = yield call(
[ChatSagas.commandService, ChatSagas.commandService.remoteCall],
const speechAuthenticationToken: Promise<string> = ChatSagas.commandService.remoteCall(
command,
endpoint.id,
!!existingFactory
);
} catch (e) {
// No-op - this appId/pass combo is not provisioned to use the speech api
}
if (token) {
const factory = yield call(createCognitiveServicesBingSpeechPonyfillFactory, {
authorizationToken: token,

const factory = yield call(createCognitiveServicesSpeechServicesPonyfillFactory, {
authorizationToken: speechAuthenticationToken,
region: 'westus', // Currently, the prod speech service is only deployed to westus
});

yield put(webSpeechFactoryUpdated(documentId, factory)); // Provide the new factory to the store
} catch (e) {
// No-op - this appId/pass combo is not provisioned to use the speech api
}

yield put(updatePendingSpeechTokenRetrieval(false));
if (resolver) {
resolver();
Expand Down
1 change: 0 additions & 1 deletion packages/app/shared/src/types/index.ts
Expand Up @@ -38,6 +38,5 @@ export * from './fileTypes';
export * from './notificationTypes';
export * from './responseTypes';
export * from './serverSettingsTypes';
export * from './speechTypes';
export * from './luisTypes';
export * from './clientAwareSettings';
4 changes: 2 additions & 2 deletions packages/emulator/core/src/authEndpoints.ts
Expand Up @@ -57,6 +57,6 @@ export const v32Authentication = {
};

export const speech = {
// Access token for Bing Speech Api
tokenEndpoint: 'https://login.botframework.com/v3/speechtoken',
// Access token for Cognitive Services API
tokenEndpoint: 'https://login.botframework.com/v3/speechtoken/speechservices',
};
176 changes: 121 additions & 55 deletions packages/emulator/core/src/facility/botEndpoint.spec.ts
Expand Up @@ -38,92 +38,157 @@ import { authentication, usGovernmentAuthentication } from '../authEndpoints';
import BotEndpoint from './botEndpoint';

describe('BotEndpoint', () => {
it('should return the speech token if it already exists', async () => {
it('should determine whether a token will expire within a time period', () => {
const endpoint = new BotEndpoint();
endpoint.speechToken = 'someToken';
const currentTime = Date.now();
endpoint.speechAuthenticationToken = {
expireAt: currentTime + 100, // 100 ms in the future
} as any;

expect((endpoint as any).willTokenExpireWithin(5000)).toBe(true);
});

it('should return the speech token if it already exists', async () => {
const endpoint = new BotEndpoint('', '', '', 'msaAppId', 'msaAppPw');
endpoint.speechAuthenticationToken = {
accessToken: 'someToken',
region: 'westus2',
expireAt: Date.now() + 10 * 1000 * 60, // expires in 10 minutes
tokenLife: 10 * 1000 * 60, // token life of 10 minutes
};
const refresh = false;
const token = await endpoint.getSpeechToken(refresh);
expect(token).toBe('someToken');
});

it('should return a new speech token if the current token is expired', async () => {
const endpoint = new BotEndpoint('', '', '', 'msaAppId', 'msaAppPw');
endpoint.speechAuthenticationToken = {
expireAt: Date.now() - 5000,
} as any;
jest.spyOn(endpoint as any, 'fetchSpeechToken').mockResolvedValueOnce('new speech token');
const token = await endpoint.getSpeechToken();

expect(token).toBe('new speech token');
});

it('should return a new speech token if the current token is past its half life', async () => {
const endpoint = new BotEndpoint('', '', '', 'msaAppId', 'msaAppPw');
endpoint.speechAuthenticationToken = {
expireAt: Date.now() + 4 * 1000 * 60, // expires in 4 minutes
tokenLife: 10 * 1000 * 60, // token life of 10 minutes
} as any;
jest.spyOn(endpoint as any, 'fetchSpeechToken').mockResolvedValueOnce('new speech token');
const token = await endpoint.getSpeechToken();

expect(token).toBe('new speech token');
});

it('should return a new speech token if there is no existing token or if the refresh flag is true', async () => {
const endpoint = new BotEndpoint('', '', '', 'msaAppId', 'msaAppPw');
jest.spyOn(endpoint as any, 'fetchSpeechToken').mockResolvedValueOnce('new speech token');
const token = await endpoint.getSpeechToken(true);

expect(token).toBe('new speech token');
});

it('should throw if there is no msa app id or password', async () => {
const endpoint = new BotEndpoint();
try {
await endpoint.getSpeechToken();
} catch (e) {
expect(e).toEqual(new Error('bot must have Microsoft App ID and password'));
expect(e).toEqual(new Error('Bot must have a valid Microsoft App ID and password'));
}
});

it('should return a speech token', async () => {
/* eslint-disable typescript/camelcase */
const mockResponse = {
json: async () => Promise.resolve({ access_Token: 'someSpeechToken' }),
it('should fetch a speech token', async () => {
const endpoint = new BotEndpoint();
jest.spyOn(endpoint as any, 'fetchWithAuth').mockResolvedValueOnce({
json: () =>
Promise.resolve({
// eslint-disable-next-line typescript/camelcase
access_Token: 'someSpeechToken',
region: 'westus2',
expireAt: 1234,
tokenLife: 9999,
}),
status: 200,
};
const mockFetchWithAuth = jest.fn(() => Promise.resolve(mockResponse));
const endpoint = new BotEndpoint('', '', '', 'msaAppId', 'msaAppPw');
endpoint.fetchWithAuth = mockFetchWithAuth;
const token = await endpoint.getSpeechToken();
});
const token = await (endpoint as any).fetchSpeechToken();

expect(token).toBe('someSpeechToken');
});

it('should throw if there is no access_Token in the response', async () => {
/* eslint-disable typescript/camelcase */
it('should throw when failing to read the token response', async () => {
const endpoint = new BotEndpoint();
jest.spyOn(endpoint as any, 'fetchWithAuth').mockResolvedValueOnce({
json: async () => Promise.reject(new Error('Malformed response JSON.')),
status: 200,
});

// with error in response body
let mockResponse: any = {
json: async () => Promise.resolve({ error: 'someError' }),
try {
await (endpoint as any).fetchSpeechToken();
expect(false).toBe(true); // make sure catch is hit
} catch (e) {
expect(e).toEqual(new Error(`Couldn't read speech token response: ${new Error('Malformed response JSON.')}`));
}
});

it(`should throw when the token response doesn't contain a token and has an error attached`, async () => {
const endpoint = new BotEndpoint();
jest.spyOn(endpoint as any, 'fetchWithAuth').mockResolvedValueOnce({
json: () => Promise.resolve({ error: 'Token was lost in transit.' }),
status: 200,
};
const mockFetchWithAuth = jest.fn(() => Promise.resolve(mockResponse));
const endpoint = new BotEndpoint('', '', '', 'msaAppId', 'msaAppPw');
endpoint.fetchWithAuth = mockFetchWithAuth;
});

try {
await endpoint.getSpeechToken();
await (endpoint as any).fetchSpeechToken();
expect(false).toBe(true); // make sure catch is hit
} catch (e) {
expect(e).toEqual(new Error('someError'));
expect(e).toEqual(new Error('Token was lost in transit.'));
}
});

// with no error in response body
mockResponse = {
json: async () => Promise.resolve({}),
it(`should throw when the token response doesn't contain a token nor an error`, async () => {
const endpoint = new BotEndpoint();
jest.spyOn(endpoint as any, 'fetchWithAuth').mockResolvedValueOnce({
json: () => Promise.resolve({}),
status: 200,
};
});

try {
await endpoint.getSpeechToken();
await (endpoint as any).fetchSpeechToken();
expect(false).toBe(true); // make sure catch is hit
} catch (e) {
expect(e).toEqual(new Error('could not retrieve speech token'));
expect(e).toEqual(new Error('Could not retrieve speech token'));
}
});

it('should throw if the call to the speech service returns a 401', async () => {
/* eslint-disable typescript/camelcase */
const mockResponse: any = {
it(`should throw when the token endpoint returns a 401`, async () => {
const endpoint = new BotEndpoint();
jest.spyOn(endpoint as any, 'fetchWithAuth').mockResolvedValueOnce({
status: 401,
};
const mockFetchWithAuth = jest.fn(() => Promise.resolve(mockResponse));
const endpoint = new BotEndpoint('', '', '', 'msaAppId', 'msaAppPw');
endpoint.fetchWithAuth = mockFetchWithAuth;
});

try {
await endpoint.getSpeechToken();
await (endpoint as any).fetchSpeechToken();
expect(false).toBe(true); // make sure catch is hit
} catch (e) {
expect(e).toEqual(new Error('not authorized to use Cognitive Services Speech API'));
expect(e).toEqual(new Error('Not authorized to use Cognitive Services Speech API'));
}
});

it('should throw if the call to the speech service returns a non-200', async () => {
/* eslint-disable typescript/camelcase */
const mockResponse: any = {
it(`should throw when the token endpoint returns an error response that is not a 401`, async () => {
const endpoint = new BotEndpoint();
jest.spyOn(endpoint as any, 'fetchWithAuth').mockResolvedValueOnce({
status: 500,
};
const mockFetchWithAuth = jest.fn(() => Promise.resolve(mockResponse));
const endpoint = new BotEndpoint('', '', '', 'msaAppId', 'msaAppPw');
endpoint.fetchWithAuth = mockFetchWithAuth;
});

try {
await endpoint.getSpeechToken();
await (endpoint as any).fetchSpeechToken();
expect(false).toBe(true); // make sure catch is hit
} catch (e) {
expect(e).toEqual(new Error('cannot retrieve speech token'));
expect(e).toEqual(new Error(`Can't retrieve speech token`));
}
});

Expand Down Expand Up @@ -191,7 +256,7 @@ describe('BotEndpoint', () => {
const accessTokenExpires = Date.now() * 2 + tokenRefreshTime;
endpoint.accessTokenExpires = accessTokenExpires;
// using non-v1.0 token & standard endpoint
const mockOauthResponse = { access_token: 'I am an access token!', expires_in: 10 };
const mockOauthResponse = { access_token: 'I am an access token!', expires_in: 10 }; // eslint-disable-line typescript/camelcase
const mockResponse = { json: jest.fn(() => Promise.resolve(mockOauthResponse)), status: 200 };
const mockFetch = jest.fn(() => Promise.resolve(mockResponse));
(endpoint as any)._options = { fetch: mockFetch };
Expand All @@ -204,9 +269,9 @@ describe('BotEndpoint', () => {
expect(mockFetch).toHaveBeenCalledWith(authentication.tokenEndpoint, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: msaAppId,
client_secret: msaPw,
grant_type: 'client_credentials', // eslint-disable-line typescript/camelcase
client_id: msaAppId, // eslint-disable-line typescript/camelcase
client_secret: msaPw, // eslint-disable-line typescript/camelcase
scope: `${msaAppId}/.default`,
} as { [key: string]: string }).toString(),
headers: {
Expand All @@ -226,9 +291,9 @@ describe('BotEndpoint', () => {
expect(mockFetch).toHaveBeenCalledWith(usGovernmentAuthentication.tokenEndpoint, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: msaAppId,
client_secret: msaPw,
grant_type: 'client_credentials', // eslint-disable-line typescript/camelcase
client_id: msaAppId, // eslint-disable-line typescript/camelcase
client_secret: msaPw, // eslint-disable-line typescript/camelcase
scope: `${msaAppId}/.default`,
atver: '1',
} as { [key: string]: string }).toString(),
Expand All @@ -255,7 +320,8 @@ describe('BotEndpoint', () => {
(endpoint as any)._options = { fetch: mockFetch };

try {
const response = await (endpoint as any).getAccessToken();
await (endpoint as any).getAccessToken();
expect(false).toBe(true); // make sure catch is hit
} catch (e) {
expect(e).toEqual({ body: undefined, message: 'Refresh access token failed with status code: 404', status: 404 });
}
Expand Down

0 comments on commit 90384b8

Please sign in to comment.