diff --git a/libraries/botbuilder-core/etc/botbuilder-core.api.md b/libraries/botbuilder-core/etc/botbuilder-core.api.md index 27644b195b..a551446d4b 100644 --- a/libraries/botbuilder-core/etc/botbuilder-core.api.md +++ b/libraries/botbuilder-core/etc/botbuilder-core.api.md @@ -277,9 +277,6 @@ export class BrowserSessionStorage extends MemoryStorage { constructor(); } -// @public (undocumented) -export const CACHED_BOT_STATE_SKIP_PROPERTIES_HANDLER_KEY: unique symbol; - // @public export interface CachedBotState { hash: string; diff --git a/libraries/botbuilder-core/src/botState.ts b/libraries/botbuilder-core/src/botState.ts index 21945fc9e1..cf05501305 100644 --- a/libraries/botbuilder-core/src/botState.ts +++ b/libraries/botbuilder-core/src/botState.ts @@ -25,8 +25,6 @@ export interface CachedBotState { hash: string; } -export const CACHED_BOT_STATE_SKIP_PROPERTIES_HANDLER_KEY = Symbol('cachedBotStateSkipPropertiesHandler'); - /** * Base class for the frameworks state persistance scopes. * @@ -40,7 +38,6 @@ export const CACHED_BOT_STATE_SKIP_PROPERTIES_HANDLER_KEY = Symbol('cachedBotSta */ export class BotState implements PropertyManager { private stateKey = Symbol('state'); - private skippedProperties = new Map(); /** * Creates a new BotState instance. @@ -81,11 +78,6 @@ export class BotState implements PropertyManager { */ load(context: TurnContext, force = false): Promise { const cached: CachedBotState = context.turnState.get(this.stateKey); - if (!context.turnState.get(CACHED_BOT_STATE_SKIP_PROPERTIES_HANDLER_KEY)) { - context.turnState.set(CACHED_BOT_STATE_SKIP_PROPERTIES_HANDLER_KEY, (key, properties) => - this.skippedProperties.set(key, properties) - ); - } if (force || !cached || !cached.state) { return Promise.resolve(this.storageKey(context)).then((key: string) => { return this.storage.read([key]).then((items: StoreItems) => { @@ -118,8 +110,7 @@ export class BotState implements PropertyManager { */ saveChanges(context: TurnContext, force = false): Promise { let cached: CachedBotState = context.turnState.get(this.stateKey); - const state = this.skipProperties(cached?.state); - if (force || (cached && cached.hash !== calculateChangeHash(state))) { + if (force || (cached && cached.hash !== calculateChangeHash(cached?.state))) { return Promise.resolve(this.storageKey(context)).then((key: string) => { if (!cached) { cached = { state: {}, hash: '' }; @@ -130,7 +121,7 @@ export class BotState implements PropertyManager { return this.storage.write(changes).then(() => { // Update change hash and cache - cached.hash = calculateChangeHash(state); + cached.hash = calculateChangeHash(cached.state); context.turnState.set(this.stateKey, cached); }); }); @@ -198,45 +189,4 @@ export class BotState implements PropertyManager { return typeof cached === 'object' && typeof cached.state === 'object' ? cached.state : undefined; } - - /** - * Skips properties from the cached state object. - * - * @remarks Primarily used to skip properties before calculating the hash value in the calculateChangeHash function. - * @param state Dictionary of state values. - * @returns Dictionary of state values, without the skipped properties. - */ - private skipProperties(state: CachedBotState['state']): CachedBotState['state'] { - if (!state || !this.skippedProperties.size) { - return state; - } - - const skipHandler = (key: string) => { - if (this.skippedProperties.has(key)) { - return this.skippedProperties.get(key) ?? [key]; - } - }; - - const inner = ([key, value], skip = []) => { - if (value === null || value === undefined || skip.includes(key)) { - return; - } - - if (Array.isArray(value)) { - return value.map((e) => inner([null, e], skip)); - } - - if (typeof value !== 'object') { - return value.valueOf(); - } - - return Object.entries(value).reduce((acc, [k, v]) => { - const skipResult = skipHandler(k) ?? []; - acc[k] = inner([k, v], [...skip, ...skipResult]); - return acc; - }, {}); - }; - - return inner([null, state]); - } } diff --git a/libraries/botbuilder-core/src/storage.ts b/libraries/botbuilder-core/src/storage.ts index bafa544466..2c772ca654 100644 --- a/libraries/botbuilder-core/src/storage.ts +++ b/libraries/botbuilder-core/src/storage.ts @@ -7,6 +7,8 @@ */ import * as z from 'zod'; +import { createHash } from 'crypto'; +import { stringify } from 'botbuilder-stdlib'; import { TurnContext } from './turnContext'; /** @@ -127,10 +129,11 @@ export function assertStoreItems(val: unknown, ..._args: unknown[]): asserts val * @returns change hash string */ export function calculateChangeHash(item: StoreItem): string { - const cpy = { ...item }; - if (cpy.eTag) { - delete cpy.eTag; - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { eTag, ...rest } = item; - return JSON.stringify(cpy); + const result = stringify(rest); + const hash = createHash('sha256', { encoding: 'utf-8' }); + const hashed = hash.update(result).digest('hex'); + return hashed; } diff --git a/libraries/botbuilder-core/tests/botState.test.js b/libraries/botbuilder-core/tests/botState.test.js index 2957bb5a99..983cc605d6 100644 --- a/libraries/botbuilder-core/tests/botState.test.js +++ b/libraries/botbuilder-core/tests/botState.test.js @@ -1,52 +1,9 @@ const assert = require('assert'); -const { - TurnContext, - BotState, - MemoryStorage, - TestAdapter, - CACHED_BOT_STATE_SKIP_PROPERTIES_HANDLER_KEY, -} = require('../'); +const { TurnContext, BotState, MemoryStorage, TestAdapter } = require('../'); const receivedMessage = { text: 'received', type: 'message' }; const storageKey = 'stateKey'; -const houseSectionsSample = { - house: { - kitchen: { - refrigerator: { - fridge: 1, - freezer: 1, - }, - chair: 6, - table: 1, - }, - bathroom: { - toilet: 1, - shower: { - showerhead: 1, - bathtub: 1, - shampoo: 2, - towel: 3, - }, - }, - bedroom: { - chair: 1, - bed: { - pillow: 3, - sheet: 1, - duvet: 1, - }, - closet: { - hanger: { - shirt: 6, - }, - shoes: 4, - pants: 5, - }, - }, - }, -}; - function cachedState(context, stateKey) { const cached = context.turnState.get(stateKey); return cached ? cached.state : undefined; @@ -149,35 +106,4 @@ describe('BotState', function () { const count = botState.createProperty('count', 1); assert(count !== undefined, 'did not successfully create PropertyAccessor.'); }); - - it('should skip properties in saveChanges()', async function () { - // Setup storage base changes. - const clone = JSON.parse(JSON.stringify(houseSectionsSample)); - delete clone.house.kitchen.refrigerator; - delete clone.house.kitchen.table; - delete clone.house.bedroom.closet.pants; - delete clone.house.kitchen.chair; - delete clone.house.bedroom.chair; - await storage.write({ [storageKey]: clone }); - await botState.load(context, true); - - // Update bot state. - const oldState = context.turnState.get(botState.stateKey); - const newState = { ...oldState, state: houseSectionsSample }; - context.turnState.set(botState.stateKey, newState); - - // Save changes into storage. - const skipProperties = context.turnState.get(CACHED_BOT_STATE_SKIP_PROPERTIES_HANDLER_KEY); - skipProperties('house', ['refrigerator', 'table', 'pants']); // Multiple props. - skipProperties('chair'); // Single prop (key used as prop). - await botState.saveChanges(context); - const updatedState = context.turnState.get(botState.stateKey); - const storageState = await storage.read([storageKey]); - - // Hash state and storage info shouldn't have changed. - const expectedStorage = storageState[storageKey]; - delete expectedStorage.eTag; - assert.equal(oldState.hash, updatedState.hash); - assert.deepStrictEqual(clone, expectedStorage); - }); }); diff --git a/libraries/botbuilder-dialogs-adaptive-runtime-integration-express/package.json b/libraries/botbuilder-dialogs-adaptive-runtime-integration-express/package.json index 77f6441e15..dd4b591f72 100644 --- a/libraries/botbuilder-dialogs-adaptive-runtime-integration-express/package.json +++ b/libraries/botbuilder-dialogs-adaptive-runtime-integration-express/package.json @@ -42,6 +42,7 @@ "botbuilder": "4.1.6", "botbuilder-dialogs-adaptive-runtime": "4.1.6", "botbuilder-dialogs-adaptive-runtime-core": "4.1.6", + "botframework-connector": "4.1.6", "express": "^4.19.2", "zod": "^3.22.4" } diff --git a/libraries/botbuilder-dialogs-adaptive-runtime-integration-express/src/index.ts b/libraries/botbuilder-dialogs-adaptive-runtime-integration-express/src/index.ts index 1e91415527..e9560c934d 100644 --- a/libraries/botbuilder-dialogs-adaptive-runtime-integration-express/src/index.ts +++ b/libraries/botbuilder-dialogs-adaptive-runtime-integration-express/src/index.ts @@ -8,6 +8,7 @@ import type { ActivityHandlerBase, BotFrameworkHttpAdapter, ChannelServiceRoutes import type { Server } from 'http'; import type { ServiceCollection } from 'botbuilder-dialogs-adaptive-runtime-core'; import { Configuration, getRuntimeServices } from 'botbuilder-dialogs-adaptive-runtime'; +import type { ConnectorClientOptions } from 'botframework-connector'; import { json, urlencoded } from 'body-parser'; // Explicitly fails checks for `""` @@ -38,6 +39,12 @@ const TypedOptions = z.object({ * Path inside applicationRoot that should be served as static files */ staticDirectory: NonEmptyString, + + /** + * Used when creating ConnectorClients. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + connectorClientOptions: z.object({}) as z.ZodObject, }); /** @@ -51,6 +58,7 @@ const defaultOptions: Options = { skillsEndpointPrefix: '/api/skills', port: 3978, staticDirectory: 'wwwroot', + connectorClientOptions: {}, }; /** @@ -65,7 +73,9 @@ export async function start( settingsDirectory: string, options: Partial = {} ): Promise { - const [services, configuration] = await getRuntimeServices(applicationRoot, settingsDirectory); + const [services, configuration] = await getRuntimeServices(applicationRoot, settingsDirectory, { + connectorClientOptions: options.connectorClientOptions, + }); const [, listen] = await makeApp(services, configuration, applicationRoot, options); listen(); diff --git a/libraries/botbuilder-dialogs-adaptive-runtime/src/index.ts b/libraries/botbuilder-dialogs-adaptive-runtime/src/index.ts index 1c68ad8af2..7fd764f8f6 100644 --- a/libraries/botbuilder-dialogs-adaptive-runtime/src/index.ts +++ b/libraries/botbuilder-dialogs-adaptive-runtime/src/index.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +/* eslint-disable @typescript-eslint/no-explicit-any */ + import * as z from 'zod'; import fs from 'fs'; import path from 'path'; @@ -529,6 +531,7 @@ function registerQnAComponents(services: ServiceCollection, configuration: Confi * * @param applicationRoot absolute path to root of application * @param settingsDirectory directory where settings files are located + * @param defaultServices services to use as default * @returns service collection and configuration * * @remarks @@ -567,7 +570,8 @@ function registerQnAComponents(services: ServiceCollection, configuration: Confi */ export async function getRuntimeServices( applicationRoot: string, - settingsDirectory: string + settingsDirectory: string, + defaultServices?: Record ): Promise<[ServiceCollection, Configuration]>; /** @@ -575,11 +579,13 @@ export async function getRuntimeServices( * * @param applicationRoot absolute path to root of application * @param configuration a fully initialized configuration instance to use + * @param defaultServices services to use as default * @returns service collection and configuration */ export async function getRuntimeServices( applicationRoot: string, - configuration: Configuration + configuration: Configuration, + defaultServices?: Record ): Promise<[ServiceCollection, Configuration]>; /** @@ -587,7 +593,8 @@ export async function getRuntimeServices( */ export async function getRuntimeServices( applicationRoot: string, - configurationOrSettingsDirectory: Configuration | string + configurationOrSettingsDirectory: Configuration | string, + defaultServices: Record = {} ): Promise<[ServiceCollection, Configuration]> { // Resolve configuration let configuration: Configuration; @@ -619,6 +626,7 @@ export async function getRuntimeServices( middlewares: new MiddlewareSet(), pathResolvers: [], serviceClientCredentialsFactory: undefined, + ...defaultServices, }); services.addFactory( diff --git a/libraries/botbuilder-dialogs-adaptive/src/actions/beginSkill.ts b/libraries/botbuilder-dialogs-adaptive/src/actions/beginSkill.ts index 8658dd9994..e5f096efc9 100644 --- a/libraries/botbuilder-dialogs-adaptive/src/actions/beginSkill.ts +++ b/libraries/botbuilder-dialogs-adaptive/src/actions/beginSkill.ts @@ -5,13 +5,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ -import { - Activity, - ActivityTypes, - StringUtils, - TurnContext, - CACHED_BOT_STATE_SKIP_PROPERTIES_HANDLER_KEY, -} from 'botbuilder'; +import { Activity, ActivityTypes, StringUtils, TurnContext } from 'botbuilder'; import { ActivityTemplate } from '../templates'; import { ActivityTemplateConverter } from '../converters'; import { AdaptiveEvents } from '../adaptiveEvents'; @@ -162,7 +156,17 @@ export class BeginSkill extends SkillDialog implements BeginSkillConfiguration { * @param options Optional options used to configure the skill dialog. */ constructor(options?: SkillDialogOptions) { - super(Object.assign({ skill: {} } as SkillDialogOptions, options)); + super( + Object.assign({ skill: {} } as SkillDialogOptions, options, { + // This is an alternative to the toJSON function because when the SkillDialogOptions are saved into the Storage, + // when the information is retrieved, it doesn't have the properties that were declared in the toJSON function. + _replacer(): Omit { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { conversationState, skillClient, conversationIdFactory, ...rest } = this; + return rest; + }, + }) + ); } /** @@ -208,10 +212,6 @@ export class BeginSkill extends SkillDialog implements BeginSkillConfiguration { // Store the initialized dialogOptions in state so we can restore these values when the dialog is resumed. dc.activeDialog.state[this._dialogOptionsStateKey] = this.dialogOptions; - // Skip properties from the bot's state cache hash due to unwanted conversationState behavior. - const skipProperties = dc.context.turnState.get(CACHED_BOT_STATE_SKIP_PROPERTIES_HANDLER_KEY); - const props: (keyof SkillDialogOptions)[] = ['conversationIdFactory', 'conversationState', 'skillClient']; - skipProperties(this._dialogOptionsStateKey, props); // Get the activity to send to the skill. options = {} as BeginSkillDialogOptions; diff --git a/libraries/botbuilder-stdlib/src/index.ts b/libraries/botbuilder-stdlib/src/index.ts index c2c43dbd8a..f31f5d62f7 100644 --- a/libraries/botbuilder-stdlib/src/index.ts +++ b/libraries/botbuilder-stdlib/src/index.ts @@ -7,3 +7,4 @@ export * as stringExt from './stringExt'; export { delay } from './delay'; export { maybeCast } from './maybeCast'; export { retry } from './retry'; +export { stringify } from './stringify'; diff --git a/libraries/botbuilder-stdlib/src/stringify.ts b/libraries/botbuilder-stdlib/src/stringify.ts new file mode 100644 index 0000000000..23b7e9909a --- /dev/null +++ b/libraries/botbuilder-stdlib/src/stringify.ts @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Encapsulates JSON.stringify function to detect and handle different types of errors (eg. Circular Structure). + * + * @remarks + * Circular Structure: + * - It detects when the provided value has circular references and replaces them with [Circular *.{path to the value being referenced}]. + * + * _replacer internal function: + * - Have similar functionality as the JSON.stringify internal toJSON function, but with the difference that only affects this stringify functionality. + * + * @example + * // Circular Structure: + * { + * "item": { + * "name": "parent", + * "parent": null, + * "child": { + * "name": "child", + * "parent": "[Circular *.item]" // => obj.item.child.parent = obj.item + * } + * } + * } + * + * @param value — A JavaScript value, usually an object or array, to be converted. + * @param replacer — A function that transforms the results. + * @param space — Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read. + * @returns {string} The converted JavaScript value to a JavaScript Object Notation (JSON) string. + */ +export function stringify(value: any, replacer?: (key: string, value: any) => any, space?: string | number): string { + if (!value) { + return ''; + } + + try { + return JSON.stringify(value, stringifyReplacer(replacer), space); + } catch (error: any) { + if (!error?.message.includes('circular structure')) { + throw error; + } + + const seen = new WeakMap(); + return JSON.stringify( + value, + function stringifyCircularReplacer(key, val) { + if (val === null || val === undefined || typeof val !== 'object') { + return val; + } + + if (key) { + const path = seen.get(val); + if (path) { + return `[Circular *.${path.join('.')}]`; + } + + const parent = seen.get(this) ?? []; + seen.set(val, [...parent, key]); + } + + const value = stringifyReplacer(replacer)(key, val); + return value; + }, + space + ); + } +} + +function stringifyReplacer(replacer?: (key: string, value: any) => any) { + return function stringifyReplacerInternal(this: any, key: string, val: any) { + const replacerValue = replacer ? replacer.call(this, key, val) : val; + if (replacerValue === null || replacerValue === undefined || typeof replacerValue !== 'object') { + return replacerValue; + } + + const result = replacerValue._replacer ? replacerValue._replacer(key) : replacerValue; + return result; + }; +} diff --git a/libraries/botbuilder-stdlib/tests/stringify.test.js b/libraries/botbuilder-stdlib/tests/stringify.test.js new file mode 100644 index 0000000000..0a4fa54f66 --- /dev/null +++ b/libraries/botbuilder-stdlib/tests/stringify.test.js @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const assert = require('assert'); +const { stringify } = require('../'); + +describe('stringify', function () { + function toJSON(_) { + return { ...this, _id: `${this._id ?? ''}:toJSON` }; + } + + function replacer(_, val) { + if (val === null || val === undefined || typeof val !== 'object' || Array.isArray(val)) { + return val; + } + + return { ...val, _id: `${val._id ?? ''}:replacer` }; + } + + function _replacer(_) { + return { ...this, _id: `${this._id ?? ''}:_replacer` }; + } + + describe('normal', function () { + let value; + + beforeEach(function () { + value = { + item: { name: 'item', value: 'testing' }, + }; + }); + + it('works with empty value', function () { + const [ + withNull, + withUndefined, + withEmptyObject, + withEmptyArray, + withEmptyString, + withZeroString, + withOneString, + withZeroNumber, + withOneNumber, + ] = [ + stringify(null), + stringify(undefined), + stringify({}), + stringify([]), + stringify(''), + stringify('0'), + stringify('1'), + stringify(0), + stringify(1), + ]; + + assert.strictEqual(withNull, ''); + assert.strictEqual(withUndefined, ''); + assert.strictEqual(withEmptyObject, '{}'); + assert.strictEqual(withEmptyArray, '[]'); + assert.strictEqual(withEmptyString, ''); + assert.strictEqual(withZeroString, '"0"'); + assert.strictEqual(withOneString, '"1"'); + assert.strictEqual(withZeroNumber, ''); + assert.strictEqual(withOneNumber, '1'); + }); + + it('works with value', function () { + const withValue = stringify(value); + + assert.strictEqual(withValue, JSON.stringify(value)); + }); + + it('works with replacer', function () { + const withReplacer = stringify(value, replacer); + + assert.ok(withReplacer.includes('"_id":":replacer"')); + assert.strictEqual(withReplacer, JSON.stringify(value, replacer)); + }); + + it('works with toJSON', function () { + value.toJSON = toJSON; + value.item.toJSON = toJSON; + const withReplacer = stringify(value, replacer); + const withoutReplacer = stringify(value); + + assert.ok(withReplacer.includes('"_id":":toJSON:replacer"')); + assert.strictEqual(withReplacer, JSON.stringify(value, replacer)); + + assert.ok(withoutReplacer.includes('"_id":":toJSON"')); + assert.strictEqual(withoutReplacer, JSON.stringify(value)); + + assert.notStrictEqual(withReplacer, withoutReplacer); + }); + + it('works with _replacer', function () { + value.toJSON = toJSON; + value._replacer = _replacer; + const withReplacerToJSON = stringify(value, replacer); + const withoutReplacerToJSON = stringify(value); + + const assertWithReplacerToJSON = JSON.stringify( + _replacer.call(JSON.parse(JSON.stringify(value, replacer))) + ); + assert.ok(withReplacerToJSON.includes('"_id":":toJSON:replacer:_replacer"')); + assert.strictEqual(withReplacerToJSON, assertWithReplacerToJSON); + + const assertWithoutReplacerToJSON = JSON.stringify(_replacer.call(JSON.parse(JSON.stringify(value)))); + assert.ok(withoutReplacerToJSON.includes('"_id":":toJSON:_replacer"')); + assert.strictEqual(withoutReplacerToJSON, assertWithoutReplacerToJSON); + + assert.notStrictEqual(withReplacerToJSON, withoutReplacerToJSON); + }); + + it('works with space', function () { + const withValue = stringify(value, null, 2); + + assert.strictEqual(withValue, JSON.stringify(value, null, 2)); + }); + }); + + describe('circular structure', function () { + let value; + + beforeEach(function () { + const result = { item: { name: 'parent', parent: null, children: [] } }; + result.item.children.push({ parent: result.item, name: 'child1' }); + result.item.children.push({ parent: result.item, name: 'child2' }); + value = result; + }); + + it('works with empty value', function () { + const [ + withNull, + withUndefined, + withEmptyObject, + withEmptyArray, + withEmptyString, + withZeroString, + withOneString, + withZeroNumber, + withOneNumber, + ] = [ + stringify(null), + stringify(undefined), + stringify({}), + stringify([]), + stringify(''), + stringify('0'), + stringify('1'), + stringify(0), + stringify(1), + ]; + + assert.strictEqual(withNull, ''); + assert.strictEqual(withUndefined, ''); + assert.strictEqual(withEmptyObject, '{}'); + assert.strictEqual(withEmptyArray, '[]'); + assert.strictEqual(withEmptyString, ''); + assert.strictEqual(withZeroString, '"0"'); + assert.strictEqual(withOneString, '"1"'); + assert.strictEqual(withZeroNumber, ''); + assert.strictEqual(withOneNumber, '1'); + }); + + it('works with value', function () { + const withValue = stringify(value); + + assert.ok(withValue.includes('[Circular *.item]')); + }); + + it('works with replacer', function () { + const withReplacer = stringify(value, replacer); + + assert.ok(withReplacer.includes('"_id":":replacer"')); + assert.ok(withReplacer.includes('[Circular *.item]')); + }); + + it('works with toJSON', function () { + value.toJSON = toJSON; + value.item.toJSON = toJSON; + const withReplacer = stringify(value, replacer); + const withoutReplacer = stringify(value); + + assert.ok(withReplacer.includes('"_id":":toJSON:replacer"')); + assert.ok(withReplacer.includes('[Circular *.children]')); + + assert.ok(withoutReplacer.includes('"_id":":toJSON"')); + assert.ok(withoutReplacer.includes('[Circular *.item.children]')); + + assert.notStrictEqual(withReplacer, withoutReplacer); + }); + + it('works with _replacer', function () { + value.toJSON = toJSON; + value._replacer = _replacer; + const withReplacerToJSON = stringify(value, replacer); + const withoutReplacerToJSON = stringify(value); + + assert.ok(withReplacerToJSON.includes('"_id":":toJSON:replacer:_replacer"')); + assert.ok(withReplacerToJSON.includes('[Circular *.item]')); + + assert.ok(withoutReplacerToJSON.includes('"_id":":toJSON:_replacer"')); + assert.ok(withoutReplacerToJSON.includes('[Circular *.item]')); + + assert.notStrictEqual(withReplacerToJSON, withoutReplacerToJSON); + }); + + it('works with space', function () { + const withValue = stringify(value, null, 2); + + assert.ok(withValue.includes('[Circular *.item]')); + assert.ok(withValue.includes('\n ')); // validate space + }); + }); +}); diff --git a/libraries/botframework-connector/package.json b/libraries/botframework-connector/package.json index 998e55be69..10c06576cd 100644 --- a/libraries/botframework-connector/package.json +++ b/libraries/botframework-connector/package.json @@ -30,6 +30,7 @@ "@azure/core-http": "^3.0.2", "@azure/identity": "^2.0.4", "@azure/msal-node": "^1.18.4", + "axios": "^0.28.0", "base64url": "^3.0.0", "botbuilder-stdlib": "4.1.6", "botframework-schema": "4.1.6", diff --git a/libraries/botframework-connector/src/auth/botFrameworkClientImpl.ts b/libraries/botframework-connector/src/auth/botFrameworkClientImpl.ts index 616cbb0615..4a78f324a5 100644 --- a/libraries/botframework-connector/src/auth/botFrameworkClientImpl.ts +++ b/libraries/botframework-connector/src/auth/botFrameworkClientImpl.ts @@ -4,27 +4,37 @@ import * as z from 'zod'; import { Activity, ChannelAccount, InvokeResponse, RoleTypes } from 'botframework-schema'; import { BotFrameworkClient } from '../skills'; +import type { ConnectorClientOptions } from '../connectorApi/models'; import { ConversationIdHttpHeaderName } from '../conversationConstants'; import { ServiceClientCredentialsFactory } from './serviceClientCredentialsFactory'; import { USER_AGENT } from './connectorFactoryImpl'; import { WebResource } from '@azure/core-http'; import { ok } from 'assert'; -import fetch from 'cross-fetch'; +import axios from 'axios'; -const botFrameworkClientFetchImpl: typeof fetch = async (input, init) => { - const url = z.string().parse(input); - const { body, headers } = z.object({ body: z.string(), headers: z.record(z.string()).optional() }).parse(init); - - const response = await fetch(url, { - method: 'POST', - body, - headers, +const botFrameworkClientFetchImpl = (connectorClientOptions: ConnectorClientOptions): typeof fetch => { + const { http: httpAgent, https: httpsAgent } = connectorClientOptions?.agentSettings ?? { + http: undefined, + https: undefined, + }; + const axiosInstance = axios.create({ + httpAgent, + httpsAgent, + validateStatus: (): boolean => true, }); - return { - status: response.status, - json: async () => response.body, - } as Response; + return async (input, init?): Promise => { + const url = z.string().parse(input); + const { body, headers } = z.object({ body: z.string(), headers: z.record(z.string()).optional() }).parse(init); + + const response = await axiosInstance.post(url, body, { + headers, + }); + return { + status: response.status, + json: () => response.data, + } as Response; + }; }; /** @@ -36,13 +46,24 @@ export class BotFrameworkClientImpl implements BotFrameworkClient { * @param credentialsFactory A [ServiceClientCredentialsFactory](xref:botframework-connector.ServiceClientCredentialsFactory) instance. * @param loginEndpoint The login url. * @param botFrameworkClientFetch A custom Fetch implementation to be used in the [BotFrameworkClient](xref:botframework-connector.BotFrameworkClient). + * @param connectorClientOptions A [ConnectorClientOptions](xref:botframework-connector.ConnectorClientOptions) object. */ constructor( private readonly credentialsFactory: ServiceClientCredentialsFactory, private readonly loginEndpoint: string, - private readonly botFrameworkClientFetch = botFrameworkClientFetchImpl + private readonly botFrameworkClientFetch?: ReturnType, + private readonly connectorClientOptions?: ConnectorClientOptions ) { - ok(typeof botFrameworkClientFetch === 'function'); + this.botFrameworkClientFetch ??= botFrameworkClientFetchImpl(this.connectorClientOptions); + + ok(typeof this.botFrameworkClientFetch === 'function'); + } + + private toJSON() { + // Ignore ConnectorClientOptions, as it could contain Circular Structure behavior. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { connectorClientOptions, ...rest } = this; + return rest; } /** diff --git a/libraries/botframework-connector/src/auth/parameterizedBotFrameworkAuthentication.ts b/libraries/botframework-connector/src/auth/parameterizedBotFrameworkAuthentication.ts index 41398dd369..bbfee0ee6a 100644 --- a/libraries/botframework-connector/src/auth/parameterizedBotFrameworkAuthentication.ts +++ b/libraries/botframework-connector/src/auth/parameterizedBotFrameworkAuthentication.ts @@ -204,7 +204,8 @@ export class ParameterizedBotFrameworkAuthentication extends BotFrameworkAuthent return new BotFrameworkClientImpl( this.credentialsFactory, this.toChannelFromBotLoginUrl, - this.botFrameworkClientFetch + this.botFrameworkClientFetch, + this.connectorClientOptions ); } diff --git a/libraries/botframework-connector/src/connectorApi/models/index.ts b/libraries/botframework-connector/src/connectorApi/models/index.ts index 8b4a7f9709..cb441f1a8d 100644 --- a/libraries/botframework-connector/src/connectorApi/models/index.ts +++ b/libraries/botframework-connector/src/connectorApi/models/index.ts @@ -6,6 +6,8 @@ import { ServiceClientOptions, RequestOptionsBase, HttpResponse } from "@azure/core-http"; import { AttachmentInfo, ChannelAccount, ConversationResourceResponse, ConversationsResult, PagedMembersResult, ResourceResponse } from "botframework-schema"; +import type { Agent as HttpAgent } from "http"; +import type { Agent as HttpsAgent } from "https"; export * from "botframework-schema"; /** @@ -17,6 +19,19 @@ export interface ConnectorClientOptions extends ServiceClientOptions { * but is required if using the ConnectorClient outside of the adapter. */ baseUri?: string; + + /** + * HTTP and HTTPS agents which will be used for every HTTP request (Node.js only). + */ + agentSettings?: AgentSettings; +} + +/** + * HTTP and HTTPS agents (Node.js only) + */ +export interface AgentSettings { + http: HttpAgent; + https: HttpsAgent; } /**