diff --git a/package-lock.json b/package-lock.json index e7c6c1213..4fb0a7e03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10310,7 +10310,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -10675,7 +10676,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -10723,6 +10725,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -10761,11 +10764,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.0.3", - "bundled": true + "bundled": true, + "optional": true } } }, diff --git a/packages/app/client/src/commands/uiCommands.spec.ts b/packages/app/client/src/commands/uiCommands.spec.ts index fa4b21d13..6fe74b41a 100644 --- a/packages/app/client/src/commands/uiCommands.spec.ts +++ b/packages/app/client/src/commands/uiCommands.spec.ts @@ -49,9 +49,9 @@ import { SecretPromptDialogContainer, } from '../ui/dialogs'; import { CommandServiceImpl } from '../platform/commands/commandServiceImpl'; -import { BotActionType } from '../data/action/botActions'; import { ExplorerActions } from '../data/action/explorerActions'; import { SWITCH_DEBUG_MODE } from '../data/action/debugModeAction'; +import { ActiveBotHelper } from '../ui/helpers/activeBotHelper'; import { registerCommands } from './uiCommands'; @@ -156,9 +156,11 @@ describe('the uiCommands', () => { dispatchedActions.push(action); return action; }; - registry.getCommand(Commands.SwitchDebugMode).handler(DebugMode.Sidecar); - expect(dispatchedActions.length).toBe(3); - [BotActionType.close, ExplorerActions.Show, SWITCH_DEBUG_MODE].forEach((type, index) => + const closeActiveBotSpy = jest.spyOn(ActiveBotHelper, 'closeActiveBot').mockResolvedValueOnce(true); + await registry.getCommand(Commands.SwitchDebugMode).handler(DebugMode.Sidecar); + expect(dispatchedActions.length).toBe(2); + expect(closeActiveBotSpy).toHaveBeenCalled(); + [ExplorerActions.Show, SWITCH_DEBUG_MODE].forEach((type, index) => expect(type).toEqual(dispatchedActions[index].type) ); }); diff --git a/packages/app/client/src/commands/uiCommands.ts b/packages/app/client/src/commands/uiCommands.ts index f94e935ca..5b0ea5041 100644 --- a/packages/app/client/src/commands/uiCommands.ts +++ b/packages/app/client/src/commands/uiCommands.ts @@ -37,7 +37,6 @@ import { ServiceTypes } from 'botframework-config/lib/schema'; import * as Constants from '../constants'; import { azureArmTokenDataChanged, beginAzureAuthWorkflow, invalidateArmToken } from '../data/action/azureAuthActions'; -import { closeBot } from '../data/action/botActions'; import { switchDebugMode } from '../data/action/debugModeAction'; import * as EditorActions from '../data/action/editorActions'; import * as NavBarActions from '../data/action/navBarActions'; @@ -63,6 +62,7 @@ import { import * as ExplorerActions from '../data/action/explorerActions'; import { closeConversation } from '../data/action/chatActions'; import { close } from '../data/action/editorActions'; +import { ActiveBotHelper } from '../ui/helpers/activeBotHelper'; /** Register UI commands (toggling UI) */ export function registerCommands(commandRegistry: CommandRegistry) { @@ -135,12 +135,12 @@ export function registerCommands(commandRegistry: CommandRegistry) { // --------------------------------------------------------------------------- // Debug mode from main - commandRegistry.registerCommand(UI.SwitchDebugMode, (debugMode: DebugMode) => { + commandRegistry.registerCommand(UI.SwitchDebugMode, async (debugMode: DebugMode) => { const { editor: { editors, activeEditor }, } = store.getState(); const { documents } = editors[activeEditor]; - store.dispatch(closeBot()); + await ActiveBotHelper.closeActiveBot(); store.dispatch(ExplorerActions.showExplorer(debugMode !== DebugMode.Sidecar)); store.dispatch(switchDebugMode(debugMode)); // Close all active conversations - this is a clean wipe of all active conversations diff --git a/packages/app/client/src/ui/editor/emulator/emulator.scss b/packages/app/client/src/ui/editor/emulator/emulator.scss index c828b0a75..b8b30a27b 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.scss +++ b/packages/app/client/src/ui/editor/emulator/emulator.scss @@ -84,7 +84,7 @@ .restart-icon { &::before { -webkit-mask: url(../../media/ic_refresh.svg); } } - .save-transcript-icon { + .save-icon { margin-left: 20px; &::before { -webkit-mask: url(../../media/ic_save.svg); } diff --git a/packages/app/client/src/ui/editor/emulator/emulator.scss.d.ts b/packages/app/client/src/ui/editor/emulator/emulator.scss.d.ts index 07ad97db2..f09615e2b 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.scss.d.ts +++ b/packages/app/client/src/ui/editor/emulator/emulator.scss.d.ts @@ -4,7 +4,7 @@ export const vertical: string; export const header: string; export const toolbarIcon: string; export const restartIcon: string; -export const saveTranscriptIcon: string; +export const saveIcon: string; export const content: string; export const presentation: string; export const chatPanel: string; diff --git a/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx index 51e28599c..b970adf1e 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx +++ b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx @@ -35,12 +35,14 @@ import * as React from 'react'; import { Provider } from 'react-redux'; import { createStore } from 'redux'; import { mount, shallow } from 'enzyme'; -import { DebugMode, SharedConstants } from '@bfemulator/app-shared'; +import { DebugMode, newNotification, SharedConstants } from '@bfemulator/app-shared'; import base64Url from 'base64url'; import { disable, enable } from '../../../data/action/presentationActions'; import { clearLog, newConversation, setInspectorObjects } from '../../../data/action/chatActions'; import { updateDocument } from '../../../data/action/editorActions'; +import { CommandServiceImpl } from '../../../platform/commands/commandServiceImpl'; +import { beginAdd } from '../../../data/action/notificationActions'; import { Emulator, RestartConversationOptions } from './emulator'; import { EmulatorContainer } from './emulatorContainer'; @@ -135,7 +137,7 @@ describe('', () => { mockDispatch = jest.spyOn(mockStore, 'dispatch'); wrapper = mount( - + ); node = wrapper.find(Emulator); @@ -284,23 +286,20 @@ describe('', () => { }); it('should export a transcript', () => { - mockStoreState.chat.chats.doc1.directLine = { - conversationId: 'convo1', - }; - wrapper = shallow( - null)} - newConversation={jest.fn(() => null)} - mode={'transcript'} - document={mockStoreState.chat.chats.doc1} - /> - ); - instance = wrapper.instance(); - instance.onExportClick(); + instance.onExportTranscriptClick(); expect(mockRemoteCallsMade).toHaveLength(1); expect(mockRemoteCallsMade[0].commandName).toBe(SharedConstants.Commands.Emulator.SaveTranscriptToFile); - expect(mockRemoteCallsMade[0].args).toEqual(['convo1']); + expect(mockRemoteCallsMade[0].args).toEqual([16, 'convo1']); + }); + + it('should report a notification when exporting a transcript fails', async () => { + jest.spyOn(CommandServiceImpl, 'remoteCall').mockRejectedValueOnce({ message: 'oh noes!' }); + await instance.onExportTranscriptClick(); + const notification = newNotification('oh noes!'); + notification.timestamp = jasmine.any(Number) as any; + + expect(mockDispatch).toHaveBeenCalledWith(beginAdd(notification)); }); it('should start a new conversation', async () => { diff --git a/packages/app/client/src/ui/editor/emulator/emulator.tsx b/packages/app/client/src/ui/editor/emulator/emulator.tsx index 5bdedd662..f9386c5f2 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.tsx +++ b/packages/app/client/src/ui/editor/emulator/emulator.tsx @@ -37,8 +37,7 @@ import { SplitButton, Splitter } from '@bfemulator/ui-react'; import base64Url from 'base64url'; import { IEndpointService } from 'botframework-config/lib/schema'; import * as React from 'react'; -import { newNotification, Notification, SharedConstants } from '@bfemulator/app-shared'; -import { DebugMode } from '@bfemulator/app-shared'; +import { DebugMode, newNotification, Notification, SharedConstants, ValueTypesMask } from '@bfemulator/app-shared'; import { Document } from '../../../data/reducer/editor'; import { CommandServiceImpl } from '../../../platform/commands/commandServiceImpl'; @@ -72,6 +71,7 @@ export interface EmulatorProps { enablePresentationMode?: (enabled: boolean) => void; endpointId?: string; endpointService?: IEndpointService; + exportItems?: (types: ValueTypesMask, conversationId: string) => Promise; mode?: EmulatorMode; newConversation?: (documentId: string, options: any) => void; presentationModeEnabled?: boolean; @@ -262,24 +262,34 @@ export class Emulator extends React.Component { renderDefaultView(): JSX.Element { const { NewUserId, SameUserId } = RestartConversationOptions; + const { mode, debugMode } = this.props; return (
- {this.props.mode === 'livechat' && ( + {mode === 'livechat' && (
- + {debugMode === DebugMode.Normal && ( + + )} + {/*{debugMode === DebugMode.Sidecar && (*/} + {/* */} + {/* Save bot state*/} + {/* */} + {/*)}*/}
)} @@ -370,13 +380,22 @@ export class Emulator extends React.Component { break; } }; - - private onExportClick = (): void => { - if (this.props.document.directLine) { - CommandServiceImpl.remoteCall( - SharedConstants.Commands.Emulator.SaveTranscriptToFile, - this.props.document.directLine.conversationId - ); + // Uncomment when ready to export bot state + // private onExportBotStateClick = async (): Promise => { + // try { + // await this.props.exportItems(ValueTypesMask.BotState, this.props.conversationId); + // } catch (e) { + // const notification = newNotification(e.message); + // this.props.createErrorNotification(notification); + // } + // }; + + private onExportTranscriptClick = async (): Promise => { + try { + await this.props.exportItems(ValueTypesMask.Activity, this.props.conversationId); + } catch (e) { + const notification = newNotification(e.message); + this.props.createErrorNotification(notification); } }; diff --git a/packages/app/client/src/ui/editor/emulator/emulatorContainer.ts b/packages/app/client/src/ui/editor/emulator/emulatorContainer.ts index d58a69b94..5ff098987 100644 --- a/packages/app/client/src/ui/editor/emulator/emulatorContainer.ts +++ b/packages/app/client/src/ui/editor/emulator/emulatorContainer.ts @@ -32,6 +32,7 @@ // import { connect } from 'react-redux'; import { Notification, SharedConstants } from '@bfemulator/app-shared'; +import { ValueTypesMask } from '@bfemulator/app-shared/src'; import { RootState } from '../../../data/store'; import * as PresentationActions from '../../../data/action/presentationActions'; @@ -65,6 +66,8 @@ const mapDispatchToProps = (dispatch): EmulatorProps => ({ createErrorNotification: (notification: Notification) => dispatch(beginAdd(notification)), trackEvent: (name: string, properties?: { [key: string]: any }) => CommandServiceImpl.remoteCall(SharedConstants.Commands.Telemetry.TrackEvent, name, properties).catch(), + exportItems: (valueTypes: ValueTypesMask, conversationId: string) => + CommandServiceImpl.remoteCall(SharedConstants.Commands.Emulator.SaveTranscriptToFile, valueTypes, conversationId), }); export const EmulatorContainer = connect( diff --git a/packages/app/client/src/ui/helpers/activeBotHelper.ts b/packages/app/client/src/ui/helpers/activeBotHelper.ts index 4d79d35a5..e180f5b16 100644 --- a/packages/app/client/src/ui/helpers/activeBotHelper.ts +++ b/packages/app/client/src/ui/helpers/activeBotHelper.ts @@ -83,7 +83,7 @@ export const ActiveBotHelper = new (class { /** Sets a bot as active * @param bot Bot to set as active */ - async setActiveBot(bot: BotConfigWithPath): Promise { + async setActiveBot(bot: BotConfigWithPath): Promise { try { // set the bot as active on the server side const botDirectory = await CommandServiceImpl.remoteCall(SharedConstants.Commands.Bot.SetActive, bot); @@ -104,21 +104,19 @@ export const ActiveBotHelper = new (class { } /** tell the server-side the active bot is now closed */ - closeActiveBot(): Promise { - return CommandServiceImpl.remoteCall(Bot.Close) - .then(() => { - store.dispatch(BotActions.closeBot()); - CommandServiceImpl.remoteCall(SharedConstants.Commands.Electron.SetTitleBar, ''); - }) - .catch(err => { - const errMsg = `Error while closing active bot: ${err}`; - const notification = newNotification(errMsg); - store.dispatch(beginAdd(notification)); - throw new Error(errMsg); - }); + async closeActiveBot(): Promise { + try { + await CommandServiceImpl.remoteCall(Bot.Close); + store.dispatch(BotActions.closeBot()); + await CommandServiceImpl.remoteCall(SharedConstants.Commands.Electron.SetTitleBar, ''); + } catch (err) { + const errMsg = `Error while closing active bot: ${err}`; + const notification = newNotification(errMsg); + store.dispatch(beginAdd(notification)); + } } - async botAlreadyOpen(): Promise { + async botAlreadyOpen(): Promise { // TODO - localization return await CommandServiceImpl.remoteCall(Electron.ShowMessageBox, true, { buttons: ['OK'], @@ -131,7 +129,7 @@ export const ActiveBotHelper = new (class { }); } - async confirmAndCreateBot(botToCreate: BotConfigWithPath, secret: string): Promise { + async confirmAndCreateBot(botToCreate: BotConfigWithPath, secret: string): Promise { // prompt the user to confirm the switch const result = await this.confirmSwitchBot(); diff --git a/packages/app/main/src/botHelpers.ts b/packages/app/main/src/botHelpers.ts index 6c15ca8d7..b4b1976c3 100644 --- a/packages/app/main/src/botHelpers.ts +++ b/packages/app/main/src/botHelpers.ts @@ -186,14 +186,9 @@ export async function removeBotFromList(botPath: string): Promise { } export function getTranscriptsPath(activeBot: BotConfigWithPath, conversation: Conversation): string { - if (conversation.mode === 'livechat-url') { + if (!activeBot || conversation.mode === 'livechat-url') { return path.join(electron.app.getPath('downloads'), './transcripts'); } - - if (activeBot) { - const dirName = path.dirname(activeBot.path); - return path.join(dirName, './transcripts'); - } - - return '/'; + const dirName = path.dirname(activeBot.path); + return path.join(dirName, './transcripts'); } diff --git a/packages/app/main/src/commands/emulatorCommands.spec.ts b/packages/app/main/src/commands/emulatorCommands.spec.ts index 9d9a4161d..2db6fec29 100644 --- a/packages/app/main/src/commands/emulatorCommands.spec.ts +++ b/packages/app/main/src/commands/emulatorCommands.spec.ts @@ -40,6 +40,7 @@ import { BotConfigWithPathImpl, CommandRegistryImpl } from '@bfemulator/sdk-shar import { BotConfiguration } from 'botframework-config'; import { newBot, newEndpoint, SharedConstants } from '@bfemulator/app-shared'; import { Conversation } from '@bfemulator/emulator-core'; +import { ValueTypesMask } from '@bfemulator/app-shared'; import * as store from '../botData/store'; import { getStore as getSettingsStore } from '../settingsData/store'; @@ -422,7 +423,7 @@ describe('The emulatorCommands', () => { const patchBotJsonSpy = jest.spyOn((botHelpers as any).default, 'patchBotsJson').mockResolvedValue(true); const command = mockCommandRegistry.getCommand(SharedConstants.Commands.Emulator.SaveTranscriptToFile); - await command.handler('1234'); + await command.handler(ValueTypesMask.Activity, '1234'); expect(getActiveBotSpy).toHaveBeenCalled(); expect(conversationByIdSpy).toHaveBeenCalledWith('1234'); diff --git a/packages/app/main/src/commands/emulatorCommands.ts b/packages/app/main/src/commands/emulatorCommands.ts index 082e4de2d..4667b8679 100644 --- a/packages/app/main/src/commands/emulatorCommands.ts +++ b/packages/app/main/src/commands/emulatorCommands.ts @@ -59,7 +59,7 @@ export function registerCommands(commandRegistry: CommandRegistryImpl) { // Saves the conversation to a transcript file, with user interaction to set filename. commandRegistry.registerCommand( Commands.SaveTranscriptToFile, - async (conversationId: string): Promise => { + async (valueTypes: number, conversationId: string): Promise => { const activeBot: BotConfigWithPath = getActiveBot(); const conversation = Emulator.getInstance().framework.server.botEmulator.facilities.conversations.conversationById( conversationId @@ -85,7 +85,7 @@ export function registerCommands(commandRegistry: CommandRegistryImpl) { if (filename && filename.length) { mkdirpSync(path.dirname(filename)); - const transcripts = await conversation.getTranscript(); + const transcripts = await conversation.getTranscript(valueTypes); writeFile(filename, transcripts); TelemetryService.trackEvent('transcript_save'); } diff --git a/packages/app/shared/src/enums/ValueTypes.ts b/packages/app/shared/src/enums/ValueTypes.ts index f698a8ede..b33378e85 100644 --- a/packages/app/shared/src/enums/ValueTypes.ts +++ b/packages/app/shared/src/enums/ValueTypes.ts @@ -37,3 +37,17 @@ export enum ValueTypes { Error = 'https://www.botframework.com/schemas/error', Activity = 'https://www.botframework.com/schemas/activity', } + +export class ValueTypesMask { + public static [ValueTypes.BotState] = 0b1; + public static [ValueTypes.Debug] = 0b10; + public static [ValueTypes.Diff] = 0b100; + public static [ValueTypes.Error] = 0b1000; + public static [ValueTypes.Activity] = 0b10000; + public static BotState = 0b1; + public static Debug = 0b10; + public static Diff = 0b100; + public static Error = 0b1000; + public static Activity = 0b10000; + private constructor() {} +} diff --git a/packages/emulator/core/src/directLine/middleware/startConversation.ts b/packages/emulator/core/src/directLine/middleware/startConversation.ts index 0de95ae7b..7ac27f57c 100644 --- a/packages/emulator/core/src/directLine/middleware/startConversation.ts +++ b/packages/emulator/core/src/directLine/middleware/startConversation.ts @@ -65,9 +65,8 @@ export default function startConversation(botEmulator: BotEmulator) { // Sends "user added to conversation" await conversation.sendConversationUpdate([currentUser], undefined); created = true; - } else { - const botIsNotInConversation = conversation.members.findIndex(user => user.id === botEndpoint.botId) === -1; - if (botEndpoint && botIsNotInConversation) { + } else if (botEndpoint && !conversationId.endsWith('transcript')) { + if (conversation.members.findIndex(user => user.id === botEndpoint.botId) === -1) { // Adds bot to conversation and sends "bot added to conversation" conversation.addMember(botEndpoint.botId, 'Bot'); } else { diff --git a/packages/emulator/core/src/facility/conversation.ts b/packages/emulator/core/src/facility/conversation.ts index 32b5e4a5e..859d34d39 100644 --- a/packages/emulator/core/src/facility/conversation.ts +++ b/packages/emulator/core/src/facility/conversation.ts @@ -42,6 +42,8 @@ import { externalLinkItem, isLocalHostUrl, LogLevel, + networkRequestItem, + networkResponseItem, PaymentOperations, PaymentRequest, PaymentRequestComplete, @@ -54,14 +56,14 @@ import { import { Activity, Attachment, + ChannelAccount, ConversationAccount, IContactRelationUpdateActivity, IInvokeActivity, IMessageActivity, - ChannelAccount, } from 'botframework-schema'; -import { networkRequestItem, networkResponseItem } from '@bfemulator/sdk-shared'; import { ChatMode } from '@bfemulator/app-shared'; +import { ValueTypesMask } from '@bfemulator/app-shared'; import { BotEmulator } from '../botEmulator'; import { TokenCache } from '../userToken/tokenCache'; @@ -667,10 +669,23 @@ export default class Conversation extends EventEmitter { }); } - public async getTranscript(): Promise { - // Currently, we only export transcript of activities - // TODO: Think about "member join/left", "typing", "activity update/delete", etc. - const activities = this.transcript.filter(record => record.type === 'activity add').map(record => record.activity); + /** + * Gets the transcript, extracting values based on + * the (optional) valueTypesToExtract bitmask. If the valueTypesToExtract is + * not included in the bitmask, the entire activity is + * included. Otherwise, the value of the activity is extracted. + * + * @param valueTypesToExtract a bitmask representing the value Types to extract from the activity + */ + public async getTranscript(valueTypesToExtract: number = 0): Promise { + const activities = this.transcript + .filter(record => record.type === 'activity add') + .map(record => { + const { activity } = record; + const extractValue = + valueTypesToExtract && activity.valueType && !!(valueTypesToExtract & ValueTypesMask[activity.valueType]); // bitwise intentional + return extractValue ? activity.value : activity; + }); for (let i = 0; i < activities.length; i++) { await this.processActivityForDataUrls(activities[i]); }