diff --git a/examples/ai/package.json b/examples/ai/package.json index d9da21ef9..15040d026 100644 --- a/examples/ai/package.json +++ b/examples/ai/package.json @@ -24,6 +24,7 @@ "@microsoft/teams.ai": "*", "@microsoft/teams.api": "*", "@microsoft/teams.apps": "*", + "@microsoft/teams.cards": "*", "@microsoft/teams.common": "*", "@microsoft/teams.dev": "*", "@microsoft/teams.openai": "*", diff --git a/examples/ai/src/feedback.ts b/examples/ai/src/feedback.ts index f2a1b40b2..b147375bb 100644 --- a/examples/ai/src/feedback.ts +++ b/examples/ai/src/feedback.ts @@ -35,8 +35,8 @@ export const handleFeedbackLoop = async ( result.content != null ? new MessageActivity(result.content) .addAiGenerated() - /** Add feedback buttons via this method */ - .addFeedback() + /** Add custom feedback button */ + .addFeedback('custom') : 'I did not generate a response.' ); diff --git a/examples/ai/src/index.ts b/examples/ai/src/index.ts index c040cbbd3..3db76f21c 100644 --- a/examples/ai/src/index.ts +++ b/examples/ai/src/index.ts @@ -1,6 +1,7 @@ import { ChatPrompt } from '@microsoft/teams.ai'; -import { MessageActivity } from '@microsoft/teams.api'; +import { cardAttachment, MessageActivity } from '@microsoft/teams.api'; import { App } from '@microsoft/teams.apps'; +import { AdaptiveCard, SubmitAction, TextBlock, TextInput } from '@microsoft/teams.cards'; import { ConsoleLogger } from '@microsoft/teams.common'; import { DevtoolsPlugin } from '@microsoft/teams.dev'; import { OpenAIChatModel } from '@microsoft/teams.openai'; @@ -144,6 +145,28 @@ app.on('message', async ({ send, activity, log }) => { await handleStatefulConversation(model, activity, send, log); }); +app.on('message.fetch-task', async ({ activity }) => { + const reaction = activity.value.data.actionValue.reaction; + + const card = new AdaptiveCard( + new TextBlock(`You reacted ${reaction}. Tell us more (optional):`, { wrap: true }), + new TextInput().withId('feedbackText').withPlaceholder('Your feedback...').withIsMultiline() + ).withActions(new SubmitAction().withTitle('Submit')); + + return { + status: 200, + body: { + task: { + type: 'continue', + value: { + title: 'Feedback', + card: cardAttachment('adaptive', card), + }, + }, + }, + }; +}); + app.on('message.submit.feedback', async ({ activity, log }) => { const { reaction, feedback: feedbackJson } = activity.value.actionValue; if (activity.replyToId == null) { diff --git a/packages/api/src/activities/activity.spec.ts b/packages/api/src/activities/activity.spec.ts index 8daf143ba..80e36ed22 100644 --- a/packages/api/src/activities/activity.spec.ts +++ b/packages/api/src/activities/activity.spec.ts @@ -140,12 +140,40 @@ describe('Activity', () => { }); }); + describe('withChannelData feedback normalization', () => { + it('should upgrade legacy feedbackLoopEnabled:true to feedbackLoop default', () => { + const activity = new Activity({ type: 'test' }).withChannelData({ feedbackLoopEnabled: true }); + + expect(activity.channelData?.feedbackLoop).toEqual({ type: 'default' }); + expect(activity.channelData?.feedbackLoopEnabled).toBeUndefined(); + }); + + it('should clear feedbackLoopEnabled when feedbackLoop is already set', () => { + const activity = new Activity({ type: 'test' }).withChannelData({ + feedbackLoop: { type: 'custom' }, + feedbackLoopEnabled: true, + }); + + expect(activity.channelData?.feedbackLoop).toEqual({ type: 'custom' }); + expect(activity.channelData?.feedbackLoopEnabled).toBeUndefined(); + }); + }); + describe('addFeedback', () => { - it('should add', () => { + it('should add default feedback loop', () => { const activity = new Activity({ type: 'test' }).addFeedback(); expect(activity.type).toEqual('test'); - expect(activity.channelData?.feedbackLoopEnabled).toEqual(true); + expect(activity.channelData?.feedbackLoop).toEqual({ type: 'default' }); + expect(activity.channelData?.feedbackLoopEnabled).toBeUndefined(); + }); + + it('should add custom feedback loop', () => { + const activity = new Activity({ type: 'test' }).addFeedback('custom'); + + expect(activity.type).toEqual('test'); + expect(activity.channelData?.feedbackLoop).toEqual({ type: 'custom' }); + expect(activity.channelData?.feedbackLoopEnabled).toBeUndefined(); }); }); diff --git a/packages/api/src/activities/activity.ts b/packages/api/src/activities/activity.ts index 6aa799ff6..ba9efe397 100644 --- a/packages/api/src/activities/activity.ts +++ b/packages/api/src/activities/activity.ts @@ -318,7 +318,16 @@ export class Activity implements IActivity { } withChannelData(value: ChannelData) { - this.channelData = { ...this.channelData, ...value }; + const merged: ChannelData = { ...this.channelData, ...value }; + + if (merged.feedbackLoop !== undefined) { + merged.feedbackLoopEnabled = undefined; + } else if (merged.feedbackLoopEnabled === true) { + merged.feedbackLoop = { type: 'default' }; + merged.feedbackLoopEnabled = undefined; + } + + this.channelData = merged; return this; } @@ -364,14 +373,17 @@ export class Activity implements IActivity { } /** - * Enable message feedback + * Enable message feedback. + * @param mode - `'default'` shows Teams' built-in thumbs up/down UI. + * `'custom'` triggers a `message/fetchTask` invoke so the bot can return its own task module dialog. */ - addFeedback() { + addFeedback(mode: 'default' | 'custom' = 'default') { if (!this.channelData) { this.channelData = {}; } - this.channelData.feedbackLoopEnabled = true; + this.channelData.feedbackLoop = { type: mode }; + this.channelData.feedbackLoopEnabled = undefined; return this; } diff --git a/packages/api/src/activities/invoke/message/fetch-task.ts b/packages/api/src/activities/invoke/message/fetch-task.ts new file mode 100644 index 000000000..49796ab6f --- /dev/null +++ b/packages/api/src/activities/invoke/message/fetch-task.ts @@ -0,0 +1,39 @@ +import { ConversationReference } from '../../../models'; +import { IActivity } from '../../activity'; + +export interface IMessageFetchTaskInvokeActivity extends IActivity<'invoke'> { + /** + * The name of the operation associated with an invoke or event activity. + */ + name: 'message/fetchTask'; + + /** + * A value that is associated with the activity. + */ + value: { + /** + * The data payload containing action name and value. + */ + data: { + /** + * The name of the action. + */ + actionName: 'feedback'; + + /** + * The nested action value containing the user's reaction. + */ + actionValue: { + /** + * The feedback button the user clicked. + */ + reaction: 'like' | 'dislike'; + }; + }; + }; + + /** + * A reference to another conversation or activity. + */ + relatesTo?: ConversationReference; +} diff --git a/packages/api/src/activities/invoke/message/index.ts b/packages/api/src/activities/invoke/message/index.ts index 2b3b01574..d4e3c37d5 100644 --- a/packages/api/src/activities/invoke/message/index.ts +++ b/packages/api/src/activities/invoke/message/index.ts @@ -1,5 +1,7 @@ +import { IMessageFetchTaskInvokeActivity } from './fetch-task'; import { IMessageSubmitActionInvokeActivity } from './submit-action'; -export type MessageInvokeActivity = IMessageSubmitActionInvokeActivity; +export type MessageInvokeActivity = IMessageFetchTaskInvokeActivity | IMessageSubmitActionInvokeActivity; +export * from './fetch-task'; export * from './submit-action'; diff --git a/packages/api/src/models/channel-data/feedback-loop.ts b/packages/api/src/models/channel-data/feedback-loop.ts new file mode 100644 index 000000000..cb684769a --- /dev/null +++ b/packages/api/src/models/channel-data/feedback-loop.ts @@ -0,0 +1,11 @@ +/** + * Configuration for a feedback loop on a message. + */ +export type FeedbackLoop = { + /** + * The type of feedback loop. + * Use `custom` to trigger a `message/fetchTask` invoke so the bot can return its own task module dialog. + * Use `default` for the standard Teams thumbs up/down UI. + */ + type: 'default' | 'custom'; +}; \ No newline at end of file diff --git a/packages/api/src/models/channel-data/index.ts b/packages/api/src/models/channel-data/index.ts index 71179e03f..e5564c93a 100644 --- a/packages/api/src/models/channel-data/index.ts +++ b/packages/api/src/models/channel-data/index.ts @@ -2,6 +2,7 @@ import { MeetingInfo } from '../meeting'; import { MembershipSource } from '../membership-source'; import { ChannelInfo } from './channel-info'; +import { FeedbackLoop } from './feedback-loop'; import { NotificationInfo } from './notification-info'; import { OnBehalfOf } from './on-behalf-of'; import { ChannelDataSettings } from './settings'; @@ -57,10 +58,18 @@ export type ChannelData = { settings?: ChannelDataSettings; /** - * Whether or not the feedback loop feature is enabled. + * Legacy feedback loop flag. Setting this to `true` is equivalent to `feedbackLoop: { type: 'default' }`. + * Prefer using `feedbackLoop` directly. */ feedbackLoopEnabled?: boolean; + /** + * Feedback loop configuration. + * Set `type` to `'custom'` to trigger a `message/fetchTask` invoke for a bot-provided task module dialog. + * Set `type` to `'default'` for the standard Teams thumbs up/down UI. + */ + feedbackLoop?: FeedbackLoop; + /** * ID of the stream. * @remarks @@ -111,3 +120,4 @@ export * from './on-behalf-of'; export * from './settings'; export * from './team-info'; export * from './tenant-info'; +export * from './feedback-loop'; \ No newline at end of file diff --git a/packages/api/src/models/invoke-response.ts b/packages/api/src/models/invoke-response.ts index 11c4cd0f6..ed192608f 100644 --- a/packages/api/src/models/invoke-response.ts +++ b/packages/api/src/models/invoke-response.ts @@ -55,6 +55,7 @@ type InvokeResponseBody = { 'task/submit': TaskModuleResponse; 'tab/fetch': TabResponse; 'tab/submit': TabResponse; + 'message/fetchTask': TaskModuleResponse; 'message/submitAction': void; 'handoff/action': void; 'signin/tokenExchange': TokenExchangeInvokeResponse | void; diff --git a/packages/apps/src/router/router.spec.ts b/packages/apps/src/router/router.spec.ts index f6190d7b5..74808ca09 100644 --- a/packages/apps/src/router/router.spec.ts +++ b/packages/apps/src/router/router.spec.ts @@ -280,5 +280,18 @@ describe('Router', () => { name: 'task/fetch' } as any)).toHaveLength(1); }); + + it('should select message fetch-task routes', () => { + const router = new Router(); + const handler = jest.fn(); + + router.on('invoke', handler); + router.on('message.fetch-task', handler); + + expect(router.select({ + type: 'invoke', + name: 'message/fetchTask', + } as any)).toHaveLength(2); + }); }); }); diff --git a/packages/apps/src/routes/invoke/index.ts b/packages/apps/src/routes/invoke/index.ts index d3d5b716e..9c234f183 100644 --- a/packages/apps/src/routes/invoke/index.ts +++ b/packages/apps/src/routes/invoke/index.ts @@ -34,6 +34,7 @@ type InvokeAliases = { 'task/submit': 'dialog.submit'; 'tab/fetch': 'tab.open'; 'tab/submit': 'tab.submit'; + 'message/fetchTask': 'message.fetch-task'; 'message/submitAction': 'message.submit'; 'handoff/action': 'handoff.action'; 'signin/tokenExchange': 'signin.token-exchange'; @@ -60,6 +61,7 @@ export const INVOKE_ALIASES: InvokeAliases = { 'task/submit': 'dialog.submit', 'tab/fetch': 'tab.open', 'tab/submit': 'tab.submit', + 'message/fetchTask': 'message.fetch-task', 'message/submitAction': 'message.submit', 'handoff/action': 'handoff.action', 'signin/tokenExchange': 'signin.token-exchange', diff --git a/packages/devtools/src/stores/ChatStore.ts b/packages/devtools/src/stores/ChatStore.ts index 8a82e28b3..5f77ea2ae 100644 --- a/packages/devtools/src/stores/ChatStore.ts +++ b/packages/devtools/src/stores/ChatStore.ts @@ -71,7 +71,7 @@ const createMessageBase = ( }; const getFeedbackState = (event: ActivityEvent) => ({ - feedbackLoopEnabled: event.body.channelData?.feedbackLoopEnabled ? true : false, + feedbackLoopEnabled: !!(event.body.channelData?.feedbackLoop ?? event.body.channelData?.feedbackLoopEnabled), }); const clearTimer = (timers: Record, id: string) => {