diff --git a/CHANGELOG.md b/CHANGELOG.md index baf6ba08e0..3470ec1853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - Added `disableFileUpload` flag to completelly disable file upload feature, in PR [#5508](https://github.com/microsoft/BotFramework-WebChat/pull/5508), by [@JamesNewbyAtMicrosoft](https://github.com/JamesNewbyAtMicrosoft) - Deprecated `hideUploadButton` in favor of `disableFileUpload`. - Updated `BasicSendBoxToolbar` to rely solely on `disableFileUpload`. +- Added support for livestreaming via `entities[type="streaminfo"]` in PR [#5517](https://github.com/microsoft/BotFramework-WebChat/pull/5517) by [@kylerohn](https://github.com/kylerohn) and [@compulim](https://github.com/compulim) ### Changed diff --git a/docs/LIVESTREAMING.md b/docs/LIVESTREAMING.md index 7917c9df1d..021c5b66d1 100644 --- a/docs/LIVESTREAMING.md +++ b/docs/LIVESTREAMING.md @@ -64,6 +64,24 @@ To simplify this documentation, we are using the term "bot" instead of "copilot" Bot developers would need to implement the livestreaming as outlined in this section. The implementation below will enable livestreaming to both Azure Bot Services and Teams. +> [!NOTE] +> +> Web Chat supports livestreaming data in both `channelData` or `entities[type="streaminfo"]` field and can be used interchangeably. The following code snippet shows the livestream data in `entities` field. +> +> ```json +> { +> "entities": [ +> { +> "streamSequence": 1, +> "streamType": "streaming", +> "type": "streaminfo" +> } +> ], +> "text": "...", +> "type": "typing" +> } +> ``` + ### Scenario 1: Livestream from start to end > In this example, we assume the bot is livestreaming the following sentence to the user: diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts index 4969bfdc76..d3cb78ceaa 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts @@ -1,153 +1,297 @@ -import type { WebChatActivity } from '../types/WebChatActivity'; +import { type ArraySlice } from 'type-fest'; + +import { type WebChatActivity } from '../types/WebChatActivity'; import getActivityLivestreamingMetadata from './getActivityLivestreamingMetadata'; -describe.each([['with "streamId"' as const], ['without "streamId"' as const]])('activity %s', variant => { - describe('activity with "streamType" of "streaming"', () => { - let activity: WebChatActivity; +function injectInto( + where: 'channelData' | 'entities', + metadata: T, + activity: Partial +): WebChatActivity { + return { + ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), + ...activity + } as WebChatActivity; +} - beforeEach(() => { - activity = { - channelData: { - ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), - streamSequence: 1, - streamType: 'streaming' - }, - id: 'a-00002', - text: 'Hello, World!', - type: 'typing' - } as any; +describe.each([['channelData' as const], ['entities' as const]])('using %s', where => { + let inject: (...args: ArraySlice, 1>) => ReturnType; + + beforeEach(() => { + inject = injectInto.bind(undefined, where); + }); + + describe.each([['with "streamId"' as const], ['without "streamId"' as const]])('activity %s', variant => { + describe('activity with "streamType" of "streaming"', () => { + let activity: WebChatActivity; + + beforeEach(() => { + activity = inject( + { + ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), + streamSequence: 1, + streamType: 'streaming' + }, + { + id: 'a-00002', + text: 'Hello, World!', + type: 'typing' + } + ); + }); + + test('should return type of "interim activity"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'interim activity')); + test('should return sequence number', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', 1)); + + if (variant === 'with "streamId"') { + test('should return session ID with value from "channelData.streamId"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); + } else { + test('should return session ID with value of "activity.id"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00002')); + } }); - test('should return type of "interim activity"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'interim activity')); - test('should return sequence number', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', 1)); + describe('activity with "streamType" of "informative message"', () => { + let activity: WebChatActivity; - if (variant === 'with "streamId"') { - test('should return session ID with value from "channelData.streamId"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); - } else { - test('should return session ID with value of "activity.id"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00002')); - } - }); + beforeEach(() => { + activity = inject( + { + ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), + streamSequence: 1, + streamType: 'informative' + }, + { + id: 'a-00002', + text: 'Hello, World!', + type: 'typing' + } + ); + }); - describe('activity with "streamType" of "informative message"', () => { - let activity: WebChatActivity; + test('should return type of "informative message"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'informative message')); + test('should return sequence number', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', 1)); - beforeEach(() => { - activity = { - channelData: { - ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), - streamSequence: 1, - streamType: 'informative' - }, - id: 'a-00002', - text: 'Hello, World!', - type: 'typing' - } as any; + if (variant === 'with "streamId"') { + test('should return session ID with value from "channelData.streamId"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); + } else { + test('should return session ID with value of "activity.id"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00002')); + } }); - test('should return type of "informative message"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'informative message')); - test('should return sequence number', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', 1)); + describe('activity with "streamType" of "final"', () => { + let activity: WebChatActivity; - if (variant === 'with "streamId"') { - test('should return session ID with value from "channelData.streamId"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); - } else { - test('should return session ID with value of "activity.id"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00002')); - } + beforeEach(() => { + activity = inject( + { + ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), + streamType: 'final' + }, + { + id: 'a-00002', + text: 'Hello, World!', + type: 'message' + } + ); + }); + + if (variant === 'with "streamId"') { + test('should return type of "final activity"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'final activity')); + test('should return sequence number of Infinity', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', Infinity)); + test('should return session ID', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); + } else { + // Final activity must have "streamId". Final activity without "streamId" is not a valid livestream activity. + test('should return undefined', () => expect(getActivityLivestreamingMetadata(activity)).toBeUndefined()); + } + }); }); - describe('activity with "streamType" of "final"', () => { - let activity: WebChatActivity; + test('activity with "streamType" of "streaming" without critical fields should return undefined', () => + expect( + getActivityLivestreamingMetadata( + inject( + { + streamType: 'streaming' + }, + { + type: 'typing' + } + ) + ) + ).toBeUndefined()); - beforeEach(() => { - activity = { - channelData: { - ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), - streamType: 'final' - }, + test.each([ + ['integer', 1, true], + ['zero', 0, false], + ['decimal', 1.234, false] + ])('activity with %s "streamSequence" should return undefined', (_, streamSequence, isValid) => { + const activity = inject( + { + streamId: 'a-00001', + streamSequence, + streamType: 'streaming' + }, + { id: 'a-00002', - text: 'Hello, World!', - type: 'message' - } as any; - }); + text: '', + type: 'typing' + } + ); - if (variant === 'with "streamId"') { - test('should return type of "final activity"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'final activity')); - test('should return sequence number of Infinity', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', Infinity)); - test('should return session ID', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); + if (isValid) { + expect(getActivityLivestreamingMetadata(activity)).toBeTruthy(); } else { - // Final activity must have "streamId". Final activity without "streamId" is not a valid livestream activity. - test('should return undefined', () => expect(getActivityLivestreamingMetadata(activity)).toBeUndefined()); + expect(getActivityLivestreamingMetadata(activity)).toBeUndefined(); } }); + + describe('"typing" activity with "streamType" of "final"', () => { + test('should return undefined if "text" field is defined', () => + expect( + getActivityLivestreamingMetadata( + inject( + { + streamId: 'a-00001', + streamType: 'final' + }, + { + id: 'a-00002', + text: 'Final "typing" activity, must not have "text".', + type: 'typing' + } + ) + ) + ).toBeUndefined()); + + test('should return truthy if "text" field is not defined', () => + expect( + getActivityLivestreamingMetadata( + inject( + { + streamId: 'a-00001', + streamType: 'final' + }, + { + id: 'a-00002', + // Final activity can be "typing" if it does not have "text". + type: 'typing' + } + ) + ) + ).toHaveProperty('type', 'final activity')); + }); + + test('activity with "streamType" of "streaming" without "content" should return type of "contentless"', () => + expect( + getActivityLivestreamingMetadata( + inject( + { + streamSequence: 1, + streamType: 'streaming' + }, + { + id: 'a-00001', + type: 'typing' + } + ) + ) + ).toHaveProperty('type', 'contentless')); }); test('invalid activity should return undefined', () => expect(getActivityLivestreamingMetadata('invalid' as any)).toBeUndefined()); -test('activity with "streamType" of "streaming" without critical fields should return undefined', () => +test('should prefer channelData over entities', () => expect( getActivityLivestreamingMetadata({ - channelData: { streamType: 'streaming' }, + channelData: { + streamId: 'a-channelData', + streamSequence: 2, + streamType: 'streaming' + }, + entities: [ + { + streamId: 'a-entities', + streamSequence: 2, + streamType: 'streaming', + type: 'streaminfo' + } + ], + id: 'a-00002', type: 'typing' } as any) - ).toBeUndefined()); - -describe.each([ - ['integer', 1, true], - ['zero', 0, false], - ['decimal', 1.234, false] -])('activity with %s "streamSequence" should return undefined', (_, streamSequence, isValid) => { - const activity = { - channelData: { streamSequence, streamType: 'streaming' }, - id: 'a-00001', - text: '', - type: 'typing' - } as any; - - if (isValid) { - expect(getActivityLivestreamingMetadata(activity)).toBeTruthy(); - } else { - expect(getActivityLivestreamingMetadata(activity)).toBeUndefined(); - } -}); + ).toHaveProperty('sessionId', 'a-channelData')); -describe('"typing" activity with "streamType" of "final"', () => { - test('should return undefined if "text" field is defined', () => - expect( - getActivityLivestreamingMetadata({ - channelData: { streamId: 'a-00001', streamType: 'final' }, - id: 'a-00002', - text: 'Final "typing" activity, must not have "text".', - type: 'typing' - } as any) - ).toBeUndefined()); +test('should prefer first entity', () => + expect( + getActivityLivestreamingMetadata({ + entities: [ + { + streamId: 'a-first', + streamSequence: 2, + streamType: 'streaming', + type: 'streaminfo' + }, + { + streamId: 'a-second', + streamSequence: 2, + streamType: 'streaming', + type: 'streaminfo' + } + ], + id: 'a-00002', + type: 'typing' + } as any) + ).toHaveProperty('sessionId', 'a-first')); - test('should return truthy if "text" field is not defined', () => - expect( - getActivityLivestreamingMetadata({ - channelData: { streamId: 'a-00001', streamType: 'final' }, - id: 'a-00002', - // Final activity can be "typing" if it does not have "text". - type: 'typing' - } as any) - ).toHaveProperty('type', 'final activity')); -}); +test('channelData-based livestreaming metadata should be harmony with other entities', () => + expect( + getActivityLivestreamingMetadata({ + channelData: { + streamSequence: 1, + streamType: 'streaming' + }, + entities: [ + { + '@context': 'https://schema.org', + '@id': '', + '@type': 'Message', + type: 'https://schema.org/Message' + } + ], + id: 'a-00001', + type: 'typing' + } as any) + ).toHaveProperty('sequenceNumber', 1)); -test('activity with "streamType" of "streaming" without "content" should return type of "contentless"', () => +test('entity-based livestreaming metadata should be harmony with other entities', () => expect( getActivityLivestreamingMetadata({ - channelData: { streamSequence: 1, streamType: 'streaming' }, + entities: [ + { + '@context': 'https://schema.org', + '@id': '', + '@type': 'Message', + type: 'https://schema.org/Message' + }, + { + streamSequence: 1, + streamType: 'streaming', + type: 'streaminfo' + } + ], id: 'a-00001', type: 'typing' } as any) - ).toHaveProperty('type', 'contentless')); + ).toHaveProperty('sequenceNumber', 1)); diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 804fed1dac..0a2e6cd918 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -1,7 +1,10 @@ import { any, array, + check, + findItem, integer, + is, literal, minValue, nonEmpty, @@ -11,8 +14,13 @@ import { pipe, safeParse, string, + transform, undefinedable, - union + union, + type ErrorMessage, + type ObjectEntries, + type ObjectIssue, + type ObjectSchema } from 'valibot'; import { type WebChatActivity } from '../types/WebChatActivity'; @@ -22,64 +30,110 @@ const EMPTY_ARRAY = Object.freeze([]); const streamSequenceSchema = pipe(number(), integer(), minValue(1)); +function eitherChannelDataOrEntities< + TActivityEntries extends ObjectEntries, + TActivityMessage extends ErrorMessage | undefined, + TMetadataEntries extends ObjectEntries, + TMetadataMessage extends ErrorMessage | undefined +>( + activitySchema: ObjectSchema, + metadataSchema: ObjectSchema +) { + const metadataInEntitiesSchema = object({ + ...metadataSchema.entries, + type: literal('streaminfo') + }); + + return union([ + object({ + ...activitySchema.entries, + channelData: metadataSchema + }), + pipe( + object({ + ...activitySchema.entries, + // We use `findItem`/`filterItem` than `variant`/`someItem` because the output of the latter is an union type. + // Consider `{ type: string } | { streamId: string; type: 'streaminfo' }`, it turns into `{ type: string }` immediately. + + // TODO: [P2] valibot@1.1.0 did not infer output type for `filterItem()`, only infer for `findItem()`. + // Bump valibot@latest and see if they solved the issue. + entities: pipe( + array(any()), + findItem(value => is(metadataInEntitiesSchema, value)), + check(value => !!value) + ) + }), + transform(({ entities, ...value }) => ({ ...value, streamInfoEntity: entities })) + ) + ]); +} + const livestreamingActivitySchema = union([ // Interim. - object({ - attachments: optional(array(any()), EMPTY_ARRAY), - channelData: object({ + eitherChannelDataOrEntities( + object({ + attachments: optional(array(any()), EMPTY_ARRAY), + id: string(), + // "text" is optional. If not set or empty, it presents a contentless activity. + text: optional(undefinedable(string())), + type: literal('typing') + }), + object({ // "streamId" is optional for the very first activity in the session. streamId: optional(undefinedable(string())), streamSequence: streamSequenceSchema, streamType: literal('streaming') - }), - id: string(), - // "text" is optional. If not set or empty, it presents a contentless activity. - text: optional(undefinedable(string())), - type: literal('typing') - }), + }) + ), // Informative message. - object({ - attachments: optional(array(any()), EMPTY_ARRAY), - channelData: object({ + eitherChannelDataOrEntities( + object({ + attachments: optional(array(any()), EMPTY_ARRAY), + id: string(), + // Informative may not have "text", but should have abstract instead (checked later) + text: optional(undefinedable(string())), + type: literal('typing'), + entities: optional(array(any()), EMPTY_ARRAY) + }), + object({ // "streamId" is optional for the very first activity in the session. streamId: optional(undefinedable(string())), streamSequence: streamSequenceSchema, streamType: literal('informative') - }), - id: string(), - // Informative may not have "text", but should have abstract instead (checked later) - text: optional(undefinedable(string())), - type: literal('typing'), - entities: optional(array(any()), EMPTY_ARRAY) - }), + }) + ), // Conclude with a message. - object({ - attachments: optional(array(any()), EMPTY_ARRAY), - channelData: object({ + eitherChannelDataOrEntities( + object({ + attachments: optional(array(any()), EMPTY_ARRAY), + id: string(), + // If "text" is empty, it represents "regretting" the livestream. + text: optional(undefinedable(string())), + type: literal('message') + }), + object({ // "streamId" is required for the final activity in the session. // The final activity must not be the sole activity in the session. streamId: pipe(string(), nonEmpty()), streamType: literal('final') - }), - id: string(), - // If "text" is empty, it represents "regretting" the livestream. - text: optional(undefinedable(string())), - type: literal('message') - }), + }) + ), // Conclude without a message. - object({ - attachments: optional(array(any()), EMPTY_ARRAY), - channelData: object({ + eitherChannelDataOrEntities( + object({ + attachments: optional(array(any()), EMPTY_ARRAY), + id: string(), + // If "text" is not set or empty, it represents "regretting" the livestream. + text: optional(undefinedable(literal(''))), + type: literal('typing') + }), + object({ // "streamId" is required for the final activity in the session. // The final activity must not be the sole activity in the session. streamId: pipe(string(), nonEmpty()), streamType: literal('final') - }), - id: string(), - // If "text" is not set or empty, it represents "regretting" the livestream. - text: optional(undefinedable(literal(''))), - type: literal('typing') - }) + }) + ) ]); /** @@ -110,19 +164,20 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi if (result.success) { const { output } = result; + const livestreamMetadata = 'channelData' in output ? output.channelData : output.streamInfoEntity; // If the activity is the first in the session, session ID should be the activity ID. - const sessionId = output.channelData.streamId || output.id; + const sessionId = livestreamMetadata.streamId || output.id; return Object.freeze( - output.channelData.streamType === 'final' + livestreamMetadata.streamType === 'final' ? { sequenceNumber: Infinity, sessionId, type: 'final activity' } : { - sequenceNumber: output.channelData.streamSequence, + sequenceNumber: livestreamMetadata.streamSequence, sessionId, type: !( output.text || @@ -130,7 +185,7 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi ('entities' in output && getOrgSchemaMessage(output.entities)?.abstract) ) ? 'contentless' - : output.channelData.streamType === 'informative' + : livestreamMetadata.streamType === 'informative' ? 'informative message' : 'interim activity' }