diff --git a/examples/cards/src/index.ts b/examples/cards/src/index.ts index 5acac80ea..a087ba490 100644 --- a/examples/cards/src/index.ts +++ b/examples/cards/src/index.ts @@ -1,5 +1,4 @@ import { - AdaptiveCardActionErrorResponse, AdaptiveCardActionMessageResponse, } from '@microsoft/teams.api'; import { App } from '@microsoft/teams.apps'; @@ -303,78 +302,55 @@ app.on('message', async ({ send, activity }) => { // await send(message); }); -app.on('card.action', async ({ activity, send }) => { - const data = activity.value?.action?.data; - if (!data?.action) { - return { - statusCode: 400, - type: 'application/vnd.microsoft.error', - value: { - code: 'BadRequest', - message: 'No action specified', - innerHttpError: { - statusCode: 400, - body: { error: 'No action specified' }, - }, - }, - } satisfies AdaptiveCardActionErrorResponse; - } - - console.debug('Received action data:', data); - - switch (data.action) { - case 'submit_basic': - await send(`Notification preference set to: ${data.notify}`); - break; - - case 'submit_form': - await send( - `Form submitted!\nName: ${data.name}\nComments: ${data.comments}\nColor: ${data.color}` - ); - break; +// Each card.action handler matches a specific action set by SubmitData +const OK_RESPONSE: AdaptiveCardActionMessageResponse = { + statusCode: 200, + type: 'application/vnd.microsoft.activity.message', + value: 'Action processed successfully', +}; - case 'create_task': - await send( - `Task created!\nTitle: ${data.title}\nDescription: ${data.description}\nPriority: ${data.priority}\nDue Date: ${data.due_date}` - ); - break; +app.on('card.action.submit_basic', async ({ activity, send }) => { + const data = activity.value.action.data; + await send(`Notification preference set to: ${data.notify}`); + return OK_RESPONSE; +}); - case 'submit_feedback': - await send(`Feedback received: ${data.feedback}`); - break; +app.on('card.action.submit_form', async ({ activity, send }) => { + const data = activity.value.action.data; + await send( + `Form submitted!\nName: ${data.name}\nComments: ${data.comments}\nColor: ${data.color}` + ); + return OK_RESPONSE; +}); - case 'purchase_item': - await send( - `Purchase request received for game: ${data.choiceGameSingle}` - ); - break; +app.on('card.action.create_task', async ({ activity, send }) => { + const data = activity.value.action.data; + await send( + `Task created!\nTitle: ${data.title}\nDescription: ${data.description}\nPriority: ${data.priority}\nDue Date: ${data.due_date}` + ); + return OK_RESPONSE; +}); - case 'save_profile': - await send( - `Profile saved!\nName: ${data.name}\nEmail: ${data.email}\nSubscribed: ${data.subscribe}` - ); - break; +app.on('card.action.submit_feedback', async ({ activity, send }) => { + const data = activity.value.action.data; + await send(`Feedback received: ${data.feedback}`); + return OK_RESPONSE; +}); - default: - return { - statusCode: 400, - type: 'application/vnd.microsoft.error', - value: { - code: 'BadRequest', - message: 'Unknown action', - innerHttpError: { - statusCode: 400, - body: { error: 'Unknown action' }, - }, - }, - } satisfies AdaptiveCardActionErrorResponse; - } +app.on('card.action.purchase_item', async ({ activity, send }) => { + const data = activity.value.action.data; + await send( + `Purchase request received for game: ${data.choiceGameSingle}` + ); + return OK_RESPONSE; +}); - return { - statusCode: 200, - type: 'application/vnd.microsoft.activity.message', - value: 'Action processed successfully', - } satisfies AdaptiveCardActionMessageResponse; +app.on('card.action.save_profile', async ({ activity, send }) => { + const data = activity.value.action.data; + await send( + `Profile saved!\nName: ${data.name}\nEmail: ${data.email}\nSubscribed: ${data.subscribe}` + ); + return OK_RESPONSE; }); app.start(process.env.PORT || 3978).catch(console.error); diff --git a/examples/dialogs/README.md b/examples/dialogs/README.md index 1037b04c5..efd133a57 100644 --- a/examples/dialogs/README.md +++ b/examples/dialogs/README.md @@ -1,6 +1,13 @@ -# Sample: cards +# Sample: dialogs -a demo of dialogs in Teams +A demo of dialogs (task modules) in Teams. + +## Features + +- **Sub-route routing** — Each dialog is handled by a targeted route (e.g., `dialog.open.simple_form`, `dialog.submit.webpage_dialog`) instead of a single catch-all handler with manual dispatch. +- **Adaptive Card dialogs** — Simple form and multi-step form examples using Adaptive Cards with `Action.Submit`. +- **Webpage dialogs** — Opens an HTML page hosted by the bot inside a Teams task module. +- **Card action routing** — Adaptive Card `Action.Execute` actions routed via `card.action.{action}` sub-routes. ## Prerequisites diff --git a/examples/dialogs/src/index.ts b/examples/dialogs/src/index.ts index 0c82ec9b6..04f824465 100644 --- a/examples/dialogs/src/index.ts +++ b/examples/dialogs/src/index.ts @@ -9,6 +9,7 @@ import { IAdaptiveCard, OpenDialogData, SubmitAction, + SubmitData, TextInput, } from '@microsoft/teams.cards'; import { ConsoleLogger } from '@microsoft/teams.common'; @@ -58,215 +59,150 @@ app.on('message', async ({ send }) => { await send(new MessageActivity('Enter this form').addCard('adaptive', card)); }); -/** -app.on('dialog.open', async ({ activity }) => { - const card: IAdaptiveCard = new AdaptiveCard()... +app.event('error', ({ error }) => { + logger.error('Error', error); +}); + +// Each dialog.open handler matches a specific dialog_id set by OpenDialogData +app.on('dialog.open.simple_form', async () => { + const dialogCard = new AdaptiveCard( + { + type: 'TextBlock', + text: 'This is a simple form', + size: 'Large', + weight: 'Bolder', + }, + new TextInput() + .withLabel('Name') + .withIsRequired() + .withId('name') + .withPlaceholder('Enter your name') + ).withActions( + new SubmitAction() + .withTitle('Submit') + .withData(new SubmitData('simple_form')) + ); - // Return an object with the task value that renders a card return { task: { type: 'continue', value: { - title: 'Title of Dialog', - card: cardAttachment('adaptive', card), + title: 'Simple Form Dialog', + card: cardAttachment('adaptive', dialogCard), }, }, }; -} -*/ - -app.event('error', ({ error }) => { - logger.error('Error', error); }); -app.on('dialog.open', async ({ activity, next }) => { - const dialogType = activity.value.data.dialog_id; - - if (dialogType === 'simple_form') { - const dialogCard = new AdaptiveCard( - { - type: 'TextBlock', - text: 'This is a simple form', - size: 'Large', - weight: 'Bolder', - }, - new TextInput() - .withLabel('Name') - .withIsRequired() - .withId('name') - .withPlaceholder('Enter your name') - ) - // Inside the dialog, the card actions for submitting the card must be - // of type Action.Submit - .withActions( - new SubmitAction() - .withTitle('Submit') - .withData({ submissiondialogtype: 'simple_form' }) - ); - - // Return an object with the task value that renders a card - return { - task: { - type: 'continue', - value: { - title: 'Simple Form Dialog', - card: cardAttachment('adaptive', dialogCard), - }, - }, - }; - } - - if (dialogType === 'webpage_dialog') { - return { - task: { - type: 'continue', - value: { - title: 'Webpage Dialog', - // Here we are using a webpage that is hosted in the same - // server as the agent. This server needs to be publicly accessible, - // needs to set up teams.js client library (https://www.npmjs.com/package/@microsoft/teams-js) - // and needs to be registered in the manifest. - url: `${process.env['BOT_ENDPOINT']}/tabs/dialog-form`, - width: 1000, - height: 800, - }, +app.on('dialog.open.webpage_dialog', async () => { + return { + task: { + type: 'continue', + value: { + title: 'Webpage Dialog', + // Here we are using a webpage that is hosted in the same + // server as the agent. This server needs to be publicly accessible, + // needs to set up teams.js client library (https://www.npmjs.com/package/@microsoft/teams-js) + // and needs to be registered in the manifest. + url: `${process.env['BOT_ENDPOINT']}/tabs/dialog-form`, + width: 1000, + height: 800, }, - }; - } - next(); + }, + }; }); -app.on('dialog.open', async ({ activity, next }) => { - const dialogType = activity.value.data.dialog_id; - - if (dialogType === 'multi_step_form') { - const dialogCard = new AdaptiveCard( - { - type: 'TextBlock', - text: 'This is a multi-step form', - size: 'Large', - weight: 'Bolder', - }, - new TextInput() - .withLabel('Name') - .withIsRequired() - .withId('name') - .withPlaceholder('Enter your name') - ) - // Inside the dialog, the card actions for submitting the card must be - // of type Action.Submit - .withActions( - new SubmitAction() - .withTitle('Submit') - .withData({ submissiondialogtype: 'webpage_dialog_step_1' }) - ); +app.on('dialog.open.multi_step_form', async () => { + const dialogCard = new AdaptiveCard( + { + type: 'TextBlock', + text: 'This is a multi-step form', + size: 'Large', + weight: 'Bolder', + }, + new TextInput() + .withLabel('Name') + .withIsRequired() + .withId('name') + .withPlaceholder('Enter your name') + ).withActions( + new SubmitAction() + .withTitle('Submit') + .withData(new SubmitData('multi_step_1')) + ); - // Return an object with the task value that renders a card - return { - task: { - type: 'continue', - value: { - title: 'Multi-step Form Dialog', - card: cardAttachment('adaptive', dialogCard), - }, + return { + task: { + type: 'continue', + value: { + title: 'Multi-step Form Dialog', + card: cardAttachment('adaptive', dialogCard), }, - }; - } - - next(); + }, + }; }); -app.on('dialog.submit', async ({ activity, send, next }) => { - const dialogType = activity.value.data?.submissiondialogtype; - - if (dialogType === 'simple_form') { - // This is data from the form that was submitted - const name = activity.value.data.name; - await send(`Hi ${name}, thanks for submitting the form!`); - return { - task: { - type: 'message', - // This appears as a final message in the dialog - value: 'Form was submitted', - }, - }; - } - - next(); +// Each dialog.submit handler matches a specific action set by SubmitData +app.on('dialog.submit.simple_form', async ({ activity, send }) => { + const name = activity.value.data.name; + await send(`Hi ${name}, thanks for submitting the form!`); + return { + task: { + type: 'message', + value: 'Form was submitted', + }, + }; }); // The submission from a webpage happens via the microsoftTeams.tasks.submitTask(formData) // call. -app.on('dialog.submit', async ({ activity, send, next }) => { - const dialogType = activity.value.data.submissiondialogtype; - - if (dialogType === 'webpage_dialog') { - // This is data from the form that was submitted - const name = activity.value.data.name; - const email = activity.value.data.email; - await send( - `Hi ${name}, thanks for submitting the form! We got that your email is ${email}` - ); - // You can also return a blank response - return { - status: 200, - }; - } - - next(); +app.on('dialog.submit.webpage_dialog', async ({ activity, send }) => { + const name = activity.value.data.name; + const email = activity.value.data.email; + await send( + `Hi ${name}, thanks for submitting the form! We got that your email is ${email}` + ); + return { status: 200 }; }); -app.on('dialog.submit', async ({ activity, send, next }) => { - const dialogType = activity.value.data.submissiondialogtype; - - if (dialogType === 'webpage_dialog_step_1') { - // This is data from the form that was submitted - const name = activity.value.data.name; - const nextStepCard = new AdaptiveCard( - { - type: 'TextBlock', - text: 'Email', - size: 'Large', - weight: 'Bolder', - }, - new TextInput() - .withLabel('Email') - .withIsRequired() - .withId('email') - .withPlaceholder('Enter your email') - ).withActions( - new SubmitAction().withTitle('Submit').withData({ - // This same handler will get called, so we need to identify the step - // in the returned data - submissiondialogtype: 'webpage_dialog_step_2', - // Carry forward data from previous step - name, - }) - ); - return { - task: { - // This indicates that the dialog flow should continue - type: 'continue', - value: { - // Here we customize the title based on the previous response - title: `Thanks ${name} - Get Email`, - card: cardAttachment('adaptive', nextStepCard), - }, +app.on('dialog.submit.multi_step_1', async ({ activity }) => { + const name = activity.value.data.name; + const nextStepCard = new AdaptiveCard( + { + type: 'TextBlock', + text: 'Email', + size: 'Large', + weight: 'Bolder', + }, + new TextInput() + .withLabel('Email') + .withIsRequired() + .withId('email') + .withPlaceholder('Enter your email') + ).withActions( + new SubmitAction() + .withTitle('Submit') + // Carry forward data from previous step + .withData(new SubmitData('multi_step_2', { name })) + ); + return { + task: { + type: 'continue', + value: { + title: `Thanks ${name} - Get Email`, + card: cardAttachment('adaptive', nextStepCard), }, - }; - } else if (dialogType === 'webpage_dialog_step_2') { - const name = activity.value.data.name; - const email = activity.value.data.email; - await send( - `Hi ${name}, thanks for submitting the form! We got that your email is ${email}` - ); - // You can also return a blank response - return { - status: 200, - }; - } + }, + }; +}); - next(); +app.on('dialog.submit.multi_step_2', async ({ activity, send }) => { + const name = activity.value.data.name; + const email = activity.value.data.email; + await send( + `Hi ${name}, thanks for submitting the form! We got that your email is ${email}` + ); + return { status: 200 }; }); app.start(process.env.PORT || 3978).catch(console.error); diff --git a/examples/dialogs/src/views/customform/index.html b/examples/dialogs/src/views/customform/index.html index 957e20a17..9f5de6c7b 100644 --- a/examples/dialogs/src/views/customform/index.html +++ b/examples/dialogs/src/views/customform/index.html @@ -32,7 +32,7 @@ let formData = { name: document.getElementById('name').value, email: document.getElementById('email').value, - submissiondialogtype: 'webpage_dialog' + action: 'webpage_dialog' }; microsoftTeams.tasks.submitTask(formData); }); diff --git a/packages/apps/src/router/router.spec.ts b/packages/apps/src/router/router.spec.ts index f6190d7b5..ff0608261 100644 --- a/packages/apps/src/router/router.spec.ts +++ b/packages/apps/src/router/router.spec.ts @@ -255,6 +255,89 @@ describe('Router', () => { } as any)).toHaveLength(1); }); + it('should select dialog open sub-routes by dialog_id', () => { + const router = new Router(); + const handler = jest.fn(); + + router.on('invoke', handler); + router.on('dialog.open', handler); + router.on('dialog.open.simple_form', handler); + + // Matches invoke + dialog.open + dialog.open.simple_form + expect(router.select({ + type: 'invoke', + name: 'task/fetch', + value: { data: { dialog_id: 'simple_form' } } + } as any)).toHaveLength(3); + + // Matches invoke + dialog.open but NOT dialog.open.simple_form + expect(router.select({ + type: 'invoke', + name: 'task/fetch', + value: { data: { dialog_id: 'other_form' } } + } as any)).toHaveLength(2); + + // Matches invoke + dialog.open (no data at all) + expect(router.select({ + type: 'invoke', + name: 'task/fetch', + value: {} + } as any)).toHaveLength(2); + }); + + it('should select dialog submit sub-routes by action', () => { + const router = new Router(); + const handler = jest.fn(); + + router.on('invoke', handler); + router.on('dialog.submit', handler); + router.on('dialog.submit.submit_form', handler); + + // Matches invoke + dialog.submit + dialog.submit.submit_form + expect(router.select({ + type: 'invoke', + name: 'task/submit', + value: { data: { action: 'submit_form' } } + } as any)).toHaveLength(3); + + // Matches invoke + dialog.submit but NOT dialog.submit.submit_form + expect(router.select({ + type: 'invoke', + name: 'task/submit', + value: { data: { action: 'other_action' } } + } as any)).toHaveLength(2); + }); + + it('should select card action sub-routes by action', () => { + const router = new Router(); + const handler = jest.fn(); + + router.on('invoke', handler); + router.on('card.action', handler); + router.on('card.action.save_profile', handler); + + // Matches invoke + card.action + card.action.save_profile + expect(router.select({ + type: 'invoke', + name: 'adaptiveCard/action', + value: { action: { type: 'Action.Execute', data: { action: 'save_profile' } } } + } as any)).toHaveLength(3); + + // Matches invoke + card.action but NOT card.action.save_profile + expect(router.select({ + type: 'invoke', + name: 'adaptiveCard/action', + value: { action: { type: 'Action.Execute', data: { action: 'other_action' } } } + } as any)).toHaveLength(2); + + // Does not match sub-route when no action field in data + expect(router.select({ + type: 'invoke', + name: 'adaptiveCard/action', + value: { action: { type: 'Action.Execute', data: { name: 'test' } } } + } as any)).toHaveLength(2); + }); + it('should select message submit action routes', () => { const router = new Router(); const handler = jest.fn(); diff --git a/packages/apps/src/router/router.ts b/packages/apps/src/router/router.ts index df5fdae1d..80f8a059e 100644 --- a/packages/apps/src/router/router.ts +++ b/packages/apps/src/router/router.ts @@ -106,6 +106,18 @@ export class Router = Record> if (activity.name === 'message/submitAction') { return event === `message.submit.${activity.value.actionName}`; } + + if (activity.name === 'task/fetch' && activity.value?.data?.dialog_id) { + return event === `dialog.open.${activity.value.data.dialog_id}`; + } + + if (activity.name === 'task/submit' && activity.value?.data?.action) { + return event === `dialog.submit.${activity.value.data.action}`; + } + + if (activity.name === 'adaptiveCard/action' && activity.value?.action?.data?.action) { + return event === `card.action.${activity.value.action.data.action}`; + } } // custom routes diff --git a/packages/apps/src/routes/invoke/card-action.ts b/packages/apps/src/routes/invoke/card-action.ts new file mode 100644 index 000000000..0edcdb59a --- /dev/null +++ b/packages/apps/src/routes/invoke/card-action.ts @@ -0,0 +1,11 @@ +import { IAdaptiveCardActionInvokeActivity, InvokeResponse } from '@microsoft/teams.api'; + +import { IActivityContext } from '../../contexts'; +import { RouteHandler } from '../../types'; + +export type CardActionSubRoutes = Record> = { + [K in `card.action.${string}`]?: RouteHandler< + IActivityContext, + InvokeResponse<'adaptiveCard/action'> | InvokeResponse<'adaptiveCard/action'>['body'] + >; +}; diff --git a/packages/apps/src/routes/invoke/dialog-open.ts b/packages/apps/src/routes/invoke/dialog-open.ts new file mode 100644 index 000000000..f7072b0a8 --- /dev/null +++ b/packages/apps/src/routes/invoke/dialog-open.ts @@ -0,0 +1,11 @@ +import { ITaskFetchInvokeActivity, InvokeResponse } from '@microsoft/teams.api'; + +import { IActivityContext } from '../../contexts'; +import { RouteHandler } from '../../types'; + +export type DialogOpenSubRoutes = Record> = { + [K in `dialog.open.${string}`]?: RouteHandler< + IActivityContext, + InvokeResponse<'task/fetch'> | InvokeResponse<'task/fetch'>['body'] + >; +}; diff --git a/packages/apps/src/routes/invoke/dialog-submit.ts b/packages/apps/src/routes/invoke/dialog-submit.ts new file mode 100644 index 000000000..17861e024 --- /dev/null +++ b/packages/apps/src/routes/invoke/dialog-submit.ts @@ -0,0 +1,11 @@ +import { ITaskSubmitInvokeActivity, InvokeResponse } from '@microsoft/teams.api'; + +import { IActivityContext } from '../../contexts'; +import { RouteHandler } from '../../types'; + +export type DialogSubmitSubRoutes = Record> = { + [K in `dialog.submit.${string}`]?: RouteHandler< + IActivityContext, + InvokeResponse<'task/submit'> | InvokeResponse<'task/submit'>['body'] + >; +}; diff --git a/packages/apps/src/routes/invoke/index.ts b/packages/apps/src/routes/invoke/index.ts index d3d5b716e..54a730e91 100644 --- a/packages/apps/src/routes/invoke/index.ts +++ b/packages/apps/src/routes/invoke/index.ts @@ -3,6 +3,9 @@ import { InvokeActivity, InvokeResponse } from '@microsoft/teams.api'; import { IActivityContext } from '../../contexts'; import { RouteHandler } from '../../types'; +import { CardActionSubRoutes } from './card-action'; +import { DialogOpenSubRoutes } from './dialog-open'; +import { DialogSubmitSubRoutes } from './dialog-submit'; import { FileConsentActivityRoutes } from './file-consent'; import { MessageExtensionSubmitActivityRoutes } from './message-extension-submit'; import { MessageSubmitActivityRoutes } from './message-submit'; @@ -14,7 +17,10 @@ export type InvokeActivityRoutes = Record< >; } & FileConsentActivityRoutes & MessageExtensionSubmitActivityRoutes & - MessageSubmitActivityRoutes; + MessageSubmitActivityRoutes & + DialogOpenSubRoutes & + DialogSubmitSubRoutes & + CardActionSubRoutes; type InvokeAliases = { 'config/fetch': 'config.open'; @@ -68,6 +74,9 @@ export const INVOKE_ALIASES: InvokeAliases = { 'adaptiveCard/action': 'card.action', }; +export * from './card-action'; +export * from './dialog-open'; +export * from './dialog-submit'; export * from './file-consent'; export * from './message-extension-submit'; export * from './message-submit';