diff --git a/CHANGELOG.md b/CHANGELOG.md index ff5da43478..56b2fcf5ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,6 +149,7 @@ Breaking changes in this release: - Breakpoint: open F12, select the subject in Element pane, type `$0.webChat.breakpoint.incomingActivity` - The `botframework-webchat` package now uses CSS modules for styling purposes, in PR [#5666](https://github.com/microsoft/BotFramework-WebChat/pull/5666), in PR [#5677](https://github.com/microsoft/BotFramework-WebChat/pull/5677) by [@OEvgeny](https://github.com/OEvgeny) - 👷🏻 Added `npm run build-browser` script for building test harness package only, in PR [#5667](https://github.com/microsoft/BotFramework-WebChat/pull/5667), by [@compulim](https://github.com/compulim) +- Added pull-based capabilities system for dynamically discovering adapter capabilities at runtime, in PR [#5679](https://github.com/microsoft/BotFramework-WebChat/pull/5679), by [@pranavjoshi001](https://github.com/pranavjoshi001) ### Changed diff --git a/__tests__/html2/hooks/useCapabilities.html b/__tests__/html2/hooks/useCapabilities.html new file mode 100644 index 0000000000..2955275abe --- /dev/null +++ b/__tests__/html2/hooks/useCapabilities.html @@ -0,0 +1,109 @@ + + + + + + +
+ + + + diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md new file mode 100644 index 0000000000..99f9d6c60a --- /dev/null +++ b/docs/CAPABILITIES.md @@ -0,0 +1,85 @@ +# Capabilities + +Web Chat supports dynamic capability discovery from adapters. Capabilities allow adapters to expose configuration values that Web Chat components can consume and react to changes. + +## Using the hook + +Use the `useCapabilities` hook with a selector to access specific capabilities: + +```js +import { useCapabilities } from 'botframework-webchat/hook'; + +// Get voice configuration +const voiceConfig = useCapabilities(caps => caps.voiceConfiguration); + +if (voiceConfig) { + console.log(`Sample rate: ${voiceConfig.sampleRate}`); + console.log(`Chunk interval: ${voiceConfig.chunkIntervalMs}ms`); +} +``` + +> **Note:** A selector function is required. This ensures components only re-render when their specific capability changes. + +## Available capabilities + +| Capability | Type | Description | +| -------------------- | -------------------------------------------------- | ----------------------------------- | +| `voiceConfiguration` | `{ chunkIntervalMs: number, sampleRate: number }` | Audio settings for Speech-to-Speech | + +## How it works + +1. **Initial fetch** - When WebChat mounts, it checks if the adapter exposes capability getter functions and retrieves initial values +2. **Event-driven updates** - When the adapter emits a `capabilitiesChanged` event, WebChat re-fetches all capabilities from the adapter +3. **Optimized re-renders** - Only components consuming changed capabilities will re-render + +## For adapter implementers + +To expose capabilities from your adapter: + +### 1. Implement getter functions + +```js +const adapter = { + // ... other adapter methods + + getVoiceConfiguration() { + return { + sampleRate: 16000, + chunkIntervalMs: 100 + }; + } +}; +``` + +### 2. Emit change events + +When capability values change, emit a `capabilitiesChanged` event activity: + +```js +// When configuration changes, emit the nudge event +adapter.activity$.next({ + type: 'event', + name: 'capabilitiesChanged', + from: { id: 'bot', role: 'bot' } +}); +``` + +WebChat will then call all capability getter functions and update consumers if values changed. + +## Adding new capabilities + +To add a new capability: + +1. Add the type to `Capabilities` in `packages/api/src/providers/Capabilities/types/Capabilities.ts` +2. Add the registry entry in `packages/api/src/providers/Capabilities/private/capabilityRegistry.ts` +3. Implement the getter in your adapter (e.g., `getMyCapability()`) + +The registry maps capability keys to getter function names: + +```js +// capabilityRegistry.ts +{ + key: 'voiceConfiguration', + getterName: 'getVoiceConfiguration' +} +``` diff --git a/package-lock.json b/package-lock.json index 2dc6203429..2bea880d81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20403,6 +20403,7 @@ "react-redux": "7.2.9", "redux": "5.0.1", "simple-update-in": "2.2.0", + "use-reduce-memo": "0.1.0", "use-ref-from": "0.1.0", "valibot": "1.2.0" }, diff --git a/packages/api/package.json b/packages/api/package.json index 6e6fea218c..cc222f2439 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -155,6 +155,7 @@ "react-redux": "7.2.9", "redux": "5.0.1", "simple-update-in": "2.2.0", + "use-reduce-memo": "0.1.0", "use-ref-from": "0.1.0", "valibot": "1.2.0" }, diff --git a/packages/api/src/boot/hook.ts b/packages/api/src/boot/hook.ts index 8d6df8447f..cd0cbff82b 100644 --- a/packages/api/src/boot/hook.ts +++ b/packages/api/src/boot/hook.ts @@ -7,6 +7,7 @@ export { useAvatarForUser, useBuildRenderActivityCallback, useByteFormatter, + useCapabilities, useConnectivityStatus, useCreateActivityRenderer, useCreateActivityStatusRenderer, diff --git a/packages/api/src/hooks/Composer.tsx b/packages/api/src/hooks/Composer.tsx index abe6b47501..046b24218d 100644 --- a/packages/api/src/hooks/Composer.tsx +++ b/packages/api/src/hooks/Composer.tsx @@ -60,6 +60,7 @@ import ActivityListenerComposer from '../providers/ActivityListener/ActivityList import ActivitySendStatusComposer from '../providers/ActivitySendStatus/ActivitySendStatusComposer'; import ActivitySendStatusTelemetryComposer from '../providers/ActivitySendStatusTelemetry/ActivitySendStatusTelemetryComposer'; import ActivityTypingComposer from '../providers/ActivityTyping/ActivityTypingComposer'; +import CapabilitiesComposer from '../providers/Capabilities/CapabilitiesComposer'; import GroupActivitiesComposer from '../providers/GroupActivities/GroupActivitiesComposer'; import PonyfillComposer from '../providers/Ponyfill/PonyfillComposer'; import StyleOptionsComposer from '../providers/StyleOptions/StyleOptionsComposer'; @@ -592,22 +593,24 @@ const ComposerCore = ({ return ( - - - - - - - - {typeof children === 'function' ? children(context) : children} - - - - - - - - + + + + + + + + + {typeof children === 'function' ? children(context) : children} + + + + + + + + + {onTelemetry && } ); diff --git a/packages/api/src/hooks/index.ts b/packages/api/src/hooks/index.ts index f5a1a959d7..da6e0151a4 100644 --- a/packages/api/src/hooks/index.ts +++ b/packages/api/src/hooks/index.ts @@ -1,3 +1,4 @@ +import useCapabilities from '../providers/Capabilities/useCapabilities'; import useGroupActivities from '../providers/GroupActivities/useGroupActivities'; import useGroupActivitiesByName from '../providers/GroupActivities/useGroupActivitiesByName'; import useActiveTyping from './useActiveTyping'; @@ -83,6 +84,7 @@ export { useAvatarForBot, useAvatarForUser, useByteFormatter, + useCapabilities, useConnectivityStatus, useCreateActivityRenderer, useCreateActivityStatusRenderer, diff --git a/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx b/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx new file mode 100644 index 0000000000..71b500cf1a --- /dev/null +++ b/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx @@ -0,0 +1,79 @@ +import React, { memo, useCallback, useMemo, type ReactNode } from 'react'; +import { useReduceMemo } from 'use-reduce-memo'; +import type { WebChatActivity } from 'botframework-webchat-core'; +import { literal, object, safeParse } from 'valibot'; + +import useActivities from '../../hooks/useActivities'; +import useWebChatAPIContext from '../../hooks/internal/useWebChatAPIContext'; +import CapabilitiesContext from './private/Context'; +import fetchCapabilitiesFromAdapter from './private/fetchCapabilitiesFromAdapter'; +import type { Capabilities } from './types/Capabilities'; + +type Props = Readonly<{ children?: ReactNode | undefined }>; + +const EMPTY_CAPABILITIES: Capabilities = Object.freeze({}); + +// Synthetic marker to trigger initial fetch - must be a stable reference +const INIT_MARKER = Object.freeze({ type: 'capabilities:init' as const }); +type InitMarker = typeof INIT_MARKER; +type ReducerInput = WebChatActivity | InitMarker; + +const CapabilitiesChangedEventSchema = object({ + type: literal('event'), + name: literal('capabilitiesChanged') +}); + +const isInitMarker = (item: ReducerInput): item is InitMarker => item === INIT_MARKER; + +const isCapabilitiesChangedEvent = (activity: ReducerInput): boolean => + safeParse(CapabilitiesChangedEventSchema, activity).success; + +/** + * Composer that derives capabilities from the adapter using a pure derivation pattern. + * + * Design principles: + * 1. Initial fetch: Pulls capabilities from adapter on mount via synthetic init marker + * 2. Event-driven updates: Re-fetches only when 'capabilitiesChanged' event is detected + * 3. Stable references: Individual capability objects maintain reference equality if unchanged + * - This ensures consumers using selectors only re-render when their capability changes + */ +const CapabilitiesComposer = memo(({ children }: Props) => { + const [activities] = useActivities(); + const { directLine } = useWebChatAPIContext(); + + const activitiesWithInit = useMemo( + () => Object.freeze([INIT_MARKER, ...activities]), + [activities] + ); + + // TODO: [P1] update to use EventTarget than activity$. + const capabilities = useReduceMemo( + activitiesWithInit, + useCallback( + (prevCapabilities: Capabilities, item: ReducerInput): Capabilities => { + const shouldFetch = isInitMarker(item) || isCapabilitiesChangedEvent(item); + + if (!shouldFetch) { + return prevCapabilities; + } + + const { capabilities: newCapabilities, hasChanged } = fetchCapabilitiesFromAdapter( + directLine, + prevCapabilities + ); + + return hasChanged ? newCapabilities : prevCapabilities; + }, + [directLine] + ), + EMPTY_CAPABILITIES + ); + + const contextValue = useMemo(() => Object.freeze({ capabilities }), [capabilities]); + + return {children}; +}); + +CapabilitiesComposer.displayName = 'CapabilitiesComposer'; + +export default CapabilitiesComposer; diff --git a/packages/api/src/providers/Capabilities/private/Context.ts b/packages/api/src/providers/Capabilities/private/Context.ts new file mode 100644 index 0000000000..b3fc2195d2 --- /dev/null +++ b/packages/api/src/providers/Capabilities/private/Context.ts @@ -0,0 +1,13 @@ +import { createContext } from 'react'; +import type { Capabilities } from '../types/Capabilities'; + +type CapabilitiesContextType = Readonly<{ + capabilities: Capabilities; +}>; + +const CapabilitiesContext = createContext(undefined); + +CapabilitiesContext.displayName = 'CapabilitiesContext'; + +export default CapabilitiesContext; +export type { CapabilitiesContextType }; diff --git a/packages/api/src/providers/Capabilities/private/capabilityRegistry.ts b/packages/api/src/providers/Capabilities/private/capabilityRegistry.ts new file mode 100644 index 0000000000..c0c6bdc3f6 --- /dev/null +++ b/packages/api/src/providers/Capabilities/private/capabilityRegistry.ts @@ -0,0 +1,45 @@ +import type { Capabilities } from '../types/Capabilities'; + +/** + * Descriptor for a capability that can be fetched from the adapter. + */ +export type CapabilityDescriptor = { + /** The key in the Capabilities object */ + readonly key: K; + /** The getter function name on the adapter (e.g., 'getVoiceConfiguration') */ + readonly getterName: string; + /** + * Custom equality comparator for this capability. + * If not provided, uses shallow object comparison. + * Return true if values are equal (should reuse previous reference). + */ + readonly isEqual?: (a: Capabilities[K], b: Capabilities[K]) => boolean; +}; + +/** + * Registry of all capabilities. + * + * To add a new capability: + * 1. Add type to Capabilities interface in types/Capabilities.ts + * 2. Add entry here with key, getterName, and optional custom isEqual + * + * @example + * // Simple capability (uses default shallowEqual) + * { key: 'voiceConfiguration', getterName: 'getVoiceConfiguration' } + * + * @example + * // Capability with custom equality check + * { + * key: 'complexConfig', + * getterName: 'getComplexConfig', + * isEqual: (a, b) => a?.nested?.value === b?.nested?.value + * } + */ +const CAPABILITY_REGISTRY: readonly CapabilityDescriptor[] = Object.freeze([ + { + key: 'voiceConfiguration', + getterName: 'getVoiceConfiguration' + } +]); + +export default CAPABILITY_REGISTRY; diff --git a/packages/api/src/providers/Capabilities/private/fetchCapabilitiesFromAdapter.ts b/packages/api/src/providers/Capabilities/private/fetchCapabilitiesFromAdapter.ts new file mode 100644 index 0000000000..56f6e6285c --- /dev/null +++ b/packages/api/src/providers/Capabilities/private/fetchCapabilitiesFromAdapter.ts @@ -0,0 +1,59 @@ +import { DirectLineJSBotConnection, isForbiddenPropertyName } from 'botframework-webchat-core'; +import type { Capabilities } from '../types/Capabilities'; +import CAPABILITY_REGISTRY from './capabilityRegistry'; +import shallowEqual from './shallowEqual'; + +type FetchResult = { + capabilities: Capabilities; + hasChanged: boolean; +}; + +/** + * Fetches all capabilities from the adapter based on the registry. + * Returns a new capabilities object with values fetched from the adapter. + */ +export default function fetchCapabilitiesFromAdapter( + directLine: DirectLineJSBotConnection, + prevCapabilities: Capabilities +): FetchResult { + let hasChanged = false; + + const entries: [string, unknown][] = []; + + for (const descriptor of CAPABILITY_REGISTRY) { + const { key, getterName, isEqual = shallowEqual } = descriptor; + + if (isForbiddenPropertyName(key) || isForbiddenPropertyName(getterName)) { + continue; + } + + // eslint-disable-next-line security/detect-object-injection + const getter = directLine[getterName]; + + if (typeof getter === 'function') { + try { + const fetchedValue = getter.call(directLine); + // eslint-disable-next-line security/detect-object-injection + const prevValue = prevCapabilities[key]; + + if (fetchedValue) { + if (typeof prevValue !== 'undefined' && isEqual(prevValue, fetchedValue)) { + entries.push([key, prevValue]); + } else { + entries.push([key, Object.freeze({ ...fetchedValue })]); + hasChanged = true; + } + } else if (typeof prevValue !== 'undefined') { + hasChanged = true; + } + } catch (error) { + console.warn(`botframework-webchat: Error calling capability ${getterName}:`, error); + } + } + } + + return { + capabilities: Object.freeze(Object.fromEntries(entries)) as Capabilities, + hasChanged + }; +} diff --git a/packages/api/src/providers/Capabilities/private/shallowEqual.ts b/packages/api/src/providers/Capabilities/private/shallowEqual.ts new file mode 100644 index 0000000000..5feaf22da4 --- /dev/null +++ b/packages/api/src/providers/Capabilities/private/shallowEqual.ts @@ -0,0 +1,18 @@ +import { isForbiddenPropertyName } from 'botframework-webchat-core'; + +// TODO: [P2] Move this file to `base` package. +export default function shallowEqual(x, y) { + if (x === y) { + return true; + } + + const xKeys = Object.keys(x); + const yKeys = Object.keys(y); + + return ( + xKeys.length === yKeys.length && + // Mitigated through denylisting. + // eslint-disable-next-line security/detect-object-injection + xKeys.every(key => !isForbiddenPropertyName(key) && yKeys.includes(key) && x[key] === y[key]) + ); +} diff --git a/packages/api/src/providers/Capabilities/private/useContext.ts b/packages/api/src/providers/Capabilities/private/useContext.ts new file mode 100644 index 0000000000..ccdd81e7dd --- /dev/null +++ b/packages/api/src/providers/Capabilities/private/useContext.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react'; +import CapabilitiesContext, { type CapabilitiesContextType } from './Context'; + +export default function useCapabilitiesContext(): CapabilitiesContextType { + const context = useContext(CapabilitiesContext); + + if (!context) { + throw new Error('botframework-webchat internal: This hook can only be used under .'); + } + + return context; +} diff --git a/packages/api/src/providers/Capabilities/types/Capabilities.ts b/packages/api/src/providers/Capabilities/types/Capabilities.ts new file mode 100644 index 0000000000..c72d96bd12 --- /dev/null +++ b/packages/api/src/providers/Capabilities/types/Capabilities.ts @@ -0,0 +1,16 @@ +/** + * All capabilities are optional as they depend on adapter/server support. + */ +type Capabilities = Readonly<{ + voiceConfiguration?: VoiceConfiguration | undefined; +}>; + +/** + * Optional for adapter/server to provide these configs for speech-to-speech. + */ +type VoiceConfiguration = Readonly<{ + chunkIntervalMs: number; + sampleRate: number; +}>; + +export type { Capabilities, VoiceConfiguration }; diff --git a/packages/api/src/providers/Capabilities/useCapabilities.ts b/packages/api/src/providers/Capabilities/useCapabilities.ts new file mode 100644 index 0000000000..3787843dd7 --- /dev/null +++ b/packages/api/src/providers/Capabilities/useCapabilities.ts @@ -0,0 +1,25 @@ +import { useMemo, useRef } from 'react'; +import useCapabilitiesContext from './private/useContext'; +import type { Capabilities } from './types/Capabilities'; + +/** + * Hook to access adapter capabilities with a selector function. + * Only triggers re-render when the selected value changes (shallow comparison). + * + * @example + * // Get voice configuration only + * const voiceConfig = useCapabilities(caps => caps.voiceConfiguration); + */ +export default function useCapabilities(selector: (capabilities: Capabilities) => T): T { + const { capabilities } = useCapabilitiesContext(); + const selectedValue = selector(capabilities); + const prevSelectedValueRef = useRef(selectedValue); + + return useMemo(() => { + if (Object.is(prevSelectedValueRef.current, selectedValue)) { + return prevSelectedValueRef.current; + } + prevSelectedValueRef.current = selectedValue; + return selectedValue; + }, [selectedValue]); +} diff --git a/packages/bundle/src/boot/actual/hook/minimal.ts b/packages/bundle/src/boot/actual/hook/minimal.ts index 69eadf421d..616f8e59ef 100644 --- a/packages/bundle/src/boot/actual/hook/minimal.ts +++ b/packages/bundle/src/boot/actual/hook/minimal.ts @@ -7,6 +7,7 @@ export { useAvatarForUser, useBuildRenderActivityCallback, useByteFormatter, + useCapabilities, useConnectivityStatus, useCreateActivityRenderer, useCreateActivityStatusRenderer, diff --git a/packages/component/src/boot/hook.ts b/packages/component/src/boot/hook.ts index 95dd2ec761..91976e66fb 100644 --- a/packages/component/src/boot/hook.ts +++ b/packages/component/src/boot/hook.ts @@ -7,6 +7,7 @@ export { useAvatarForUser, useBuildRenderActivityCallback, useByteFormatter, + useCapabilities, useConnectivityStatus, useCreateActivityRenderer, useCreateActivityStatusRenderer, diff --git a/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js b/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js index 5ff5cca2b0..3c81bfac9e 100644 --- a/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js +++ b/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js @@ -103,10 +103,43 @@ export default function createDirectLineEmulator({ autoConnect = true, ponyfill autoConnect && connectedWithResolvers.resolve(); + // Generic capabilities storage + const capabilities = new Map(); + + // Helper to emit capabilitiesChanged event + const emitCapabilitiesChangedEvent = () => { + activityDeferredObservable.next({ + from: { id: 'bot', role: 'bot' }, + id: uniqueId(), + name: 'capabilitiesChanged', + timestamp: getTimestamp(), + type: 'event' + }); + }; + const directLine = { activity$: shareObservable(activityDeferredObservable.observable), actPostActivity, connectionStatus$: shareObservable(connectionStatusDeferredObservable.observable), + + /** + * Generic capability setter - dynamically creates getter on directLine object. + * + * @example + * directLine.setCapability('getVoiceConfiguration', { voice: 'en-US', speed: 1.0 }); + * directLine.setCapability('getSessionInfo', { sessionId: '123' }, { emitEvent: false }); + */ + setCapability: (getterName, value, { emitEvent = true } = {}) => { + capabilities.set(getterName, value); + + // Dynamically add/update getter on directLine object + // eslint-disable-next-line security/detect-object-injection + directLine[getterName] = () => capabilities.get(getterName); + + if (emitEvent) { + emitCapabilitiesChangedEvent(); + } + }, end: () => { // This is a mock and will no-op on dispatch(). },