diff --git a/.changeset/teams-dialog-support.md b/.changeset/teams-dialog-support.md new file mode 100644 index 00000000..32dbc2cf --- /dev/null +++ b/.changeset/teams-dialog-support.md @@ -0,0 +1,6 @@ +--- +"@chat-adapter/teams": minor +"chat": minor +--- + +Add Teams dialog (task module) support with `actionType: "modal"` on buttons and `onOpenModal` webhook hook diff --git a/examples/nextjs-chat/src/lib/bot.tsx b/examples/nextjs-chat/src/lib/bot.tsx index 58179248..be401533 100644 --- a/examples/nextjs-chat/src/lib/bot.tsx +++ b/examples/nextjs-chat/src/lib/bot.tsx @@ -117,11 +117,13 @@ bot.onNewMention(async (thread, message) => { - + - Open Link @@ -211,7 +213,7 @@ bot.onAction("ephemeral", async (event) => { Try opening a modal from this ephemeral: - @@ -237,7 +239,6 @@ bot.onAction("ephemeral_modal", async (event) => { ); }); -// @ts-expect-error async void handler vs ModalSubmitHandler return type bot.onModalSubmit("ephemeral_modal_form", async (event) => { await event.relatedMessage?.edit( diff --git a/packages/adapter-teams/package.json b/packages/adapter-teams/package.json index 25060228..eab6c049 100644 --- a/packages/adapter-teams/package.json +++ b/packages/adapter-teams/package.json @@ -27,6 +27,7 @@ "@chat-adapter/shared": "workspace:*", "@microsoft/teams.api": "^2.0.6", "@microsoft/teams.apps": "^2.0.6", + "@microsoft/teams.cards": "^2.0.6", "@microsoft/teams.graph-endpoints": "^2.0.6", "chat": "workspace:*" }, diff --git a/packages/adapter-teams/src/bridge-adapter.ts b/packages/adapter-teams/src/bridge-adapter.ts index 3dd57ff0..4facd669 100644 --- a/packages/adapter-teams/src/bridge-adapter.ts +++ b/packages/adapter-teams/src/bridge-adapter.ts @@ -66,11 +66,13 @@ export class BridgeHttpAdapter implements IHttpServerAdapter { try { const serverResponse = await this.handler({ body: parsedBody, headers }); + const hasBody = + serverResponse.body !== undefined && serverResponse.body !== null; return new Response( - serverResponse.body ? JSON.stringify(serverResponse.body) : "{}", + hasBody ? JSON.stringify(serverResponse.body) : null, { status: serverResponse.status, - headers: { "Content-Type": "application/json" }, + headers: hasBody ? { "Content-Type": "application/json" } : undefined, } ); } catch (error) { diff --git a/packages/adapter-teams/src/cards.test.ts b/packages/adapter-teams/src/cards.test.ts index 6395ce3a..d1cbe8db 100644 --- a/packages/adapter-teams/src/cards.test.ts +++ b/packages/adapter-teams/src/cards.test.ts @@ -32,11 +32,11 @@ describe("cardToAdaptiveCard", () => { const adaptive = cardToAdaptiveCard(card); expect(adaptive.body).toHaveLength(1); - expect(adaptive.body[0]).toEqual({ + expect(adaptive.body[0]).toMatchObject({ type: "TextBlock", text: "Welcome Message", - weight: "bolder", - size: "large", + weight: "Bolder", + size: "Large", wrap: true, }); }); @@ -49,7 +49,7 @@ describe("cardToAdaptiveCard", () => { const adaptive = cardToAdaptiveCard(card); expect(adaptive.body).toHaveLength(2); - expect(adaptive.body[1]).toEqual({ + expect(adaptive.body[1]).toMatchObject({ type: "TextBlock", text: "Your package is on its way", isSubtle: true, @@ -65,10 +65,10 @@ describe("cardToAdaptiveCard", () => { const adaptive = cardToAdaptiveCard(card); expect(adaptive.body).toHaveLength(2); - expect(adaptive.body[1]).toEqual({ + expect(adaptive.body[1]).toMatchObject({ type: "Image", url: "https://example.com/product.png", - size: "stretch", + size: "Stretch", }); }); @@ -84,20 +84,20 @@ describe("cardToAdaptiveCard", () => { expect(adaptive.body).toHaveLength(3); - expect(adaptive.body[0]).toEqual({ + expect(adaptive.body[0]).toMatchObject({ type: "TextBlock", text: "Regular text", wrap: true, }); - expect(adaptive.body[1]).toEqual({ + expect(adaptive.body[1]).toMatchObject({ type: "TextBlock", text: "Bold text", wrap: true, - weight: "bolder", + weight: "Bolder", }); - expect(adaptive.body[2]).toEqual({ + expect(adaptive.body[2]).toMatchObject({ type: "TextBlock", text: "Muted text", wrap: true, @@ -114,11 +114,11 @@ describe("cardToAdaptiveCard", () => { const adaptive = cardToAdaptiveCard(card); expect(adaptive.body).toHaveLength(1); - expect(adaptive.body[0]).toEqual({ + expect(adaptive.body[0]).toMatchObject({ type: "Image", url: "https://example.com/img.png", altText: "My image", - size: "auto", + size: "Auto", }); }); @@ -129,7 +129,7 @@ describe("cardToAdaptiveCard", () => { const adaptive = cardToAdaptiveCard(card); expect(adaptive.body).toHaveLength(1); - expect(adaptive.body[0]).toEqual({ + expect(adaptive.body[0]).toMatchObject({ type: "Container", separator: true, items: [], @@ -157,21 +157,21 @@ describe("cardToAdaptiveCard", () => { expect(adaptive.body).toHaveLength(0); expect(adaptive.actions).toHaveLength(3); - expect(adaptive.actions?.[0]).toEqual({ + expect(adaptive.actions?.[0]).toMatchObject({ type: "Action.Submit", title: "Approve", data: { actionId: "approve", value: undefined }, style: "positive", }); - expect(adaptive.actions?.[1]).toEqual({ + expect(adaptive.actions?.[1]).toMatchObject({ type: "Action.Submit", title: "Reject", data: { actionId: "reject", value: "data-123" }, style: "destructive", }); - expect(adaptive.actions?.[2]).toEqual({ + expect(adaptive.actions?.[2]).toMatchObject({ type: "Action.Submit", title: "Skip", data: { actionId: "skip", value: undefined }, @@ -193,7 +193,7 @@ describe("cardToAdaptiveCard", () => { const adaptive = cardToAdaptiveCard(card); expect(adaptive.actions).toHaveLength(1); - expect(adaptive.actions?.[0]).toEqual({ + expect(adaptive.actions?.[0]).toMatchObject({ type: "Action.OpenUrl", title: "View Docs", url: "https://example.com/docs", @@ -213,7 +213,7 @@ describe("cardToAdaptiveCard", () => { const adaptive = cardToAdaptiveCard(card); expect(adaptive.body).toHaveLength(1); - expect(adaptive.body[0]).toEqual({ + expect(adaptive.body[0]).toMatchObject({ type: "FactSet", facts: [ { title: "Status", value: "Active" }, @@ -230,7 +230,7 @@ describe("cardToAdaptiveCard", () => { expect(adaptive.body).toHaveLength(1); expect(adaptive.body[0].type).toBe("Container"); - expect(adaptive.body[0].items).toHaveLength(1); + expect((adaptive.body[0] as { items: unknown[] }).items).toHaveLength(1); }); it("converts a complete card", () => { @@ -300,6 +300,29 @@ describe("cardToFallbackText", () => { }); }); +describe("cardToAdaptiveCard with modal buttons", () => { + it("adds msteams task/fetch hint for actionType modal", () => { + const card = Card({ + children: [ + Actions([ + Button({ id: "open-dialog", label: "Open", actionType: "modal" }), + ]), + ], + }); + const adaptive = cardToAdaptiveCard(card); + + expect(adaptive.actions).toHaveLength(1); + expect(adaptive.actions?.[0]).toMatchObject({ + type: "Action.Submit", + title: "Open", + data: { + actionId: "open-dialog", + msteams: { type: "task/fetch" }, + }, + }); + }); +}); + describe("cardToAdaptiveCard with CardLink", () => { it("converts CardLink to a TextBlock with markdown link", () => { const card = Card({ @@ -309,7 +332,7 @@ describe("cardToAdaptiveCard with CardLink", () => { const adaptive = cardToAdaptiveCard(card); expect(adaptive.body).toHaveLength(1); - expect(adaptive.body[0]).toEqual({ + expect(adaptive.body[0]).toMatchObject({ type: "TextBlock", text: "[Click here](https://example.com)", wrap: true, diff --git a/packages/adapter-teams/src/cards.ts b/packages/adapter-teams/src/cards.ts index 4a1315a9..6712ecc2 100644 --- a/packages/adapter-teams/src/cards.ts +++ b/packages/adapter-teams/src/cards.ts @@ -10,6 +10,23 @@ import { mapButtonStyle, cardToFallbackText as sharedCardToFallbackText, } from "@chat-adapter/shared"; +import type { + ActionArray, + ActionStyle, + CardElementArray, +} from "@microsoft/teams.cards"; +import { + AdaptiveCard, + Image as AdaptiveImage, + Column, + ColumnSet, + Container, + Fact, + FactSet, + OpenUrlAction, + SubmitAction, + TextBlock, +} from "@microsoft/teams.cards"; import type { ActionsElement, ButtonElement, @@ -30,67 +47,41 @@ import { cardChildToFallbackText } from "chat"; */ const convertEmoji = createEmojiConverter("teams"); -// Adaptive Card types (simplified) -export interface AdaptiveCard { - $schema: string; - actions?: AdaptiveCardAction[]; - body: AdaptiveCardElement[]; - type: "AdaptiveCard"; - version: string; -} - -export interface AdaptiveCardElement { - type: string; - [key: string]: unknown; -} - -export interface AdaptiveCardAction { - data?: Record; - style?: string; - title: string; - type: string; - url?: string; -} - const ADAPTIVE_CARD_SCHEMA = "http://adaptivecards.io/schemas/adaptive-card.json"; -const ADAPTIVE_CARD_VERSION = "1.4"; +const ADAPTIVE_CARD_VERSION = "1.4" as const; /** * Convert a CardElement to a Teams Adaptive Card. */ export function cardToAdaptiveCard(card: CardElement): AdaptiveCard { - const body: AdaptiveCardElement[] = []; - const actions: AdaptiveCardAction[] = []; + const body: CardElementArray = []; + const actions: ActionArray = []; // Add title as TextBlock if (card.title) { - body.push({ - type: "TextBlock", - text: convertEmoji(card.title), - weight: "bolder", - size: "large", - wrap: true, - }); + body.push( + new TextBlock(convertEmoji(card.title), { + weight: "Bolder", + size: "Large", + wrap: true, + }) + ); } // Add subtitle as TextBlock if (card.subtitle) { - body.push({ - type: "TextBlock", - text: convertEmoji(card.subtitle), - isSubtle: true, - wrap: true, - }); + body.push( + new TextBlock(convertEmoji(card.subtitle), { + isSubtle: true, + wrap: true, + }) + ); } // Add header image if present if (card.imageUrl) { - body.push({ - type: "Image", - url: card.imageUrl, - size: "stretch", - }); + body.push(new AdaptiveImage(card.imageUrl, { size: "Stretch" })); } // Convert children @@ -100,23 +91,21 @@ export function cardToAdaptiveCard(card: CardElement): AdaptiveCard { actions.push(...result.actions); } - const adaptiveCard: AdaptiveCard = { - type: "AdaptiveCard", + const adaptiveCard = new AdaptiveCard(...body).withOptions({ $schema: ADAPTIVE_CARD_SCHEMA, version: ADAPTIVE_CARD_VERSION, - body, - }; + }); if (actions.length > 0) { - adaptiveCard.actions = actions; + adaptiveCard.withActions(...actions); } return adaptiveCard; } interface ConvertResult { - actions: AdaptiveCardAction[]; - elements: AdaptiveCardElement[]; + actions: ActionArray; + elements: CardElementArray; } /** @@ -139,11 +128,9 @@ function convertChildToAdaptive(child: CardChild): ConvertResult { case "link": return { elements: [ - { - type: "TextBlock", - text: `[${convertEmoji(child.label)}](${child.url})`, + new TextBlock(`[${convertEmoji(child.label)}](${child.url})`, { wrap: true, - }, + }), ], actions: [], }; @@ -153,7 +140,7 @@ function convertChildToAdaptive(child: CardChild): ConvertResult { const text = cardChildToFallbackText(child); if (text) { return { - elements: [{ type: "TextBlock", text, wrap: true }], + elements: [new TextBlock(text, { wrap: true })], actions: [], }; } @@ -162,45 +149,35 @@ function convertChildToAdaptive(child: CardChild): ConvertResult { } } -function convertTextToElement(element: TextElement): AdaptiveCardElement { - const textBlock: AdaptiveCardElement = { - type: "TextBlock", - text: convertEmoji(element.content), +function convertTextToElement(element: TextElement): TextBlock { + const options: { wrap: boolean; weight?: "Bolder"; isSubtle?: boolean } = { wrap: true, }; if (element.style === "bold") { - textBlock.weight = "bolder"; + options.weight = "Bolder"; } else if (element.style === "muted") { - textBlock.isSubtle = true; + options.isSubtle = true; } - return textBlock; + return new TextBlock(convertEmoji(element.content), options); } -function convertImageToElement(element: ImageElement): AdaptiveCardElement { - return { - type: "Image", - url: element.url, +function convertImageToElement(element: ImageElement): AdaptiveImage { + return new AdaptiveImage(element.url, { altText: element.alt || "Image", - size: "auto", - }; + size: "Auto", + }); } -function convertDividerToElement( - _element: DividerElement -): AdaptiveCardElement { +function convertDividerToElement(_element: DividerElement): Container { // Adaptive Cards don't have a native divider, use a separator container - return { - type: "Container", - separator: true, - items: [], - }; + return new Container().withSeparator(true); } function convertActionsToElements(element: ActionsElement): ConvertResult { // In Adaptive Cards, actions go at the card level, not inline - const actions: AdaptiveCardAction[] = element.children + const actions: ActionArray = element.children .filter((child) => child.type === "button" || child.type === "link-button") .map((button) => { if (button.type === "link-button") { @@ -212,47 +189,57 @@ function convertActionsToElements(element: ActionsElement): ConvertResult { return { elements: [], actions }; } -function convertButtonToAction(button: ButtonElement): AdaptiveCardAction { - const action: AdaptiveCardAction = { - type: "Action.Submit", +function convertButtonToAction(button: ButtonElement): SubmitAction { + const data: Record = { + actionId: button.id, + value: button.value, + }; + + // Add task/fetch hint for dialog-opening buttons + if (button.actionType === "modal") { + data.msteams = { type: "task/fetch" }; + } + + const options: { + title: string; + data: Record; + style?: ActionStyle; + } = { title: convertEmoji(button.label), - data: { - actionId: button.id, - value: button.value, - }, + data, }; - const style = mapButtonStyle(button.style, "teams"); + const style = mapButtonStyle(button.style, "teams") as + | ActionStyle + | undefined; if (style) { - action.style = style; + options.style = style; } - return action; + return new SubmitAction(options); } -function convertLinkButtonToAction( - button: LinkButtonElement -): AdaptiveCardAction { - const action: AdaptiveCardAction = { - type: "Action.OpenUrl", +function convertLinkButtonToAction(button: LinkButtonElement): OpenUrlAction { + const options: { title: string; style?: ActionStyle } = { title: convertEmoji(button.label), - url: button.url, }; - const style = mapButtonStyle(button.style, "teams"); + const style = mapButtonStyle(button.style, "teams") as + | ActionStyle + | undefined; if (style) { - action.style = style; + options.style = style; } - return action; + return new OpenUrlAction(button.url, options); } function convertSectionToElements(element: SectionElement): ConvertResult { - const elements: AdaptiveCardElement[] = []; - const actions: AdaptiveCardAction[] = []; + const elements: CardElementArray = []; + const actions: ActionArray = []; // Wrap section in a container - const containerItems: AdaptiveCardElement[] = []; + const containerItems: CardElementArray = []; for (const child of element.children) { const result = convertChildToAdaptive(child); @@ -261,67 +248,41 @@ function convertSectionToElements(element: SectionElement): ConvertResult { } if (containerItems.length > 0) { - elements.push({ - type: "Container", - items: containerItems, - }); + elements.push(new Container(...containerItems)); } return { elements, actions }; } -function convertTableToElement(element: TableElement): AdaptiveCardElement { +function convertTableToElement(element: TableElement): Container { // Adaptive Cards Table element - const columns = element.headers.map((header) => ({ - type: "Column", - width: "stretch", - items: [ - { - type: "TextBlock", - text: convertEmoji(header), - weight: "bolder", - wrap: true, - }, - ], - })); - - const headerRow = { - type: "ColumnSet", - columns, - }; + const headerColumns = element.headers.map((header) => + new Column( + new TextBlock(convertEmoji(header), { weight: "Bolder", wrap: true }) + ).withOptions({ width: "stretch" }) + ); + + const headerRow = new ColumnSet().withColumns(...headerColumns); + + const dataRows = element.rows.map((row) => { + const cols = row.map((cell) => + new Column(new TextBlock(convertEmoji(cell), { wrap: true })).withOptions( + { width: "stretch" } + ) + ); + return new ColumnSet().withColumns(...cols); + }); - const dataRows = element.rows.map((row) => ({ - type: "ColumnSet", - columns: row.map((cell) => ({ - type: "Column", - width: "stretch", - items: [ - { - type: "TextBlock", - text: convertEmoji(cell), - wrap: true, - }, - ], - })), - })); - - return { - type: "Container", - items: [headerRow, ...dataRows], - }; + return new Container(headerRow, ...dataRows); } -function convertFieldsToElement(element: FieldsElement): AdaptiveCardElement { +function convertFieldsToElement(element: FieldsElement): FactSet { // Use FactSet for key-value pairs - const facts = element.children.map((field) => ({ - title: convertEmoji(field.label), - value: convertEmoji(field.value), - })); - - return { - type: "FactSet", - facts, - }; + const facts = element.children.map( + (field) => new Fact(convertEmoji(field.label), convertEmoji(field.value)) + ); + + return new FactSet(...facts); } /** diff --git a/packages/adapter-teams/src/index.ts b/packages/adapter-teams/src/index.ts index 470ac0ce..f847af90 100644 --- a/packages/adapter-teams/src/index.ts +++ b/packages/adapter-teams/src/index.ts @@ -11,6 +11,9 @@ import type { IAdaptiveCardActionInvokeActivity, IMessageActivity, IMessageReactionActivity, + ITaskFetchInvokeActivity, + ITaskSubmitInvokeActivity, + TaskModuleResponse, } from "@microsoft/teams.api"; import { MessageActivity, TypingActivity } from "@microsoft/teams.api"; import type { IActivityContext } from "@microsoft/teams.apps"; @@ -30,6 +33,7 @@ import type { ListThreadsOptions, ListThreadsResult, Logger, + ModalElement, RawMessage, ReactionEvent, StreamChunk, @@ -50,6 +54,11 @@ import { toAppOptions } from "./config"; import { handleTeamsError } from "./errors"; import { TeamsGraphReader } from "./graph-api"; import { TeamsFormatConverter } from "./markdown"; +import { + modalResponseToTaskModuleResponse, + modalToAdaptiveCard, + parseDialogSubmitValues, +} from "./modals"; import { decodeThreadId, encodeThreadId, isDM } from "./thread-id"; import type { TeamsAdapterConfig, @@ -57,9 +66,16 @@ import type { TeamsThreadId, } from "./types"; +/** Data payload from an Action.Submit button click. */ +interface ActionSubmitData { + actionId?: string; + value?: string; +} + const MESSAGEID_CAPTURE_PATTERN = /messageid=(\d+)/; const MESSAGEID_STRIP_PATTERN = /;messageid=\d+/; const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +const DEFAULT_DIALOG_OPEN_TIMEOUT_MS = 5000; // Max wait for handler to call openModal() export class TeamsAdapter implements Adapter { readonly name = "teams"; @@ -85,6 +101,9 @@ export class TeamsAdapter implements Adapter { // Convert our public config (appId/appPassword/appTenantId) to Teams SDK AppOptions this.app = new App({ ...toAppOptions(config), + client: { + headers: { "X-User-Agent": "Vercel.ChatSDK" }, + }, httpServerAdapter: this.bridgeAdapter, }); @@ -123,6 +142,22 @@ export class TeamsAdapter implements Adapter { }; }); + this.app.on( + "dialog.open", + async (ctx: IActivityContext) => { + this.cacheUserContext(ctx.activity); + return this.handleDialogOpen(ctx); + } + ); + + this.app.on( + "dialog.submit", + async (ctx: IActivityContext) => { + this.cacheUserContext(ctx.activity); + return this.handleDialogSubmit(ctx); + } + ); + this.app.on("conversationUpdate", async (ctx) => { this.cacheUserContext(ctx.activity); }); @@ -251,9 +286,7 @@ export class TeamsAdapter implements Adapter { const activity = ctx.activity; // Check if this message activity is actually a button click (Action.Submit) - const actionValue = activity.value as - | { actionId?: string; value?: string } - | undefined; + const actionValue = activity.value as ActionSubmitData | undefined; if (actionValue?.actionId) { this.handleMessageAction(activity, actionValue); return; @@ -293,7 +326,7 @@ export class TeamsAdapter implements Adapter { */ private handleMessageAction( activity: Activity, - actionValue: { actionId?: string; value?: string } + actionValue: ActionSubmitData ): void { if (!(this.chat && actionValue.actionId)) { return; @@ -346,10 +379,7 @@ export class TeamsAdapter implements Adapter { } const activity = ctx.activity; - const actionData = activity.value.action.data as { - actionId?: string; - value?: string; - }; + const actionData = activity.value.action.data as ActionSubmitData; if (!actionData.actionId) { this.logger.debug("Adaptive card action missing actionId", { @@ -394,6 +424,151 @@ export class TeamsAdapter implements Adapter { ); } + /** + * Handle dialog.open (task/fetch) invoke. + * Uses Promise.race to resolve as soon as onOpenModal fires. + */ + private async handleDialogOpen( + ctx: IActivityContext + ): Promise { + if (!this.chat) { + return undefined; + } + + const activity = ctx.activity; + const actionData = (activity.value?.data || {}) as ActionSubmitData; + + const threadId = this.encodeThreadId({ + conversationId: activity.conversation?.id || "", + serviceUrl: activity.serviceUrl || "", + }); + + let resolveModal: (result: { + modal: ModalElement; + contextId: string; + }) => void; + const modalPromise = new Promise<{ + modal: ModalElement; + contextId: string; + }>((resolve) => { + resolveModal = resolve; + }); + + const actionEvent: Omit & { + adapter: TeamsAdapter; + } = { + actionId: actionData.actionId || "dialog.open", + value: actionData.value, + user: { + userId: activity.from?.id || "unknown", + userName: activity.from?.name || "unknown", + fullName: activity.from?.name || "unknown", + isBot: false, + isMe: false, + }, + messageId: activity.replyToId || activity.id || "", + threadId, + adapter: this, + raw: activity, + // No triggerId — onOpenModal bypasses the guard + }; + + this.logger.debug("Processing Teams dialog.open", { + actionId: actionEvent.actionId, + threadId, + }); + + const webhookOptions = this.bridgeAdapter.getWebhookOptions(activity.id); + let timer: ReturnType | undefined; + + const actionPromise = this.chat.processAction(actionEvent, { + waitUntil: webhookOptions?.waitUntil ?? (() => {}), + onOpenModal: async (modal, contextId) => { + resolveModal({ modal, contextId }); + return { viewId: contextId }; + }, + }); + + const result = await Promise.race([ + modalPromise, + new Promise((resolve) => { + timer = setTimeout( + () => resolve(null), + this.config.dialogOpenTimeoutMs ?? DEFAULT_DIALOG_OPEN_TIMEOUT_MS + ); + }), + // If the action handler finishes without calling openModal, resolve + // immediately instead of waiting for the timeout. + actionPromise.then(() => null), + ]); + + if (timer) { + clearTimeout(timer); + } + + if (result) { + const card = modalToAdaptiveCard( + result.modal, + result.contextId, + result.modal.callbackId + ); + return { + task: { + type: "continue" as const, + value: { + title: result.modal.title || "Dialog", + card: { + contentType: "application/vnd.microsoft.card.adaptive", + content: card, + }, + }, + }, + }; + } + + this.logger.warn("dialog.open timed out waiting for onOpenModal"); + return undefined; + } + + /** + * Handle dialog.submit (task/submit) invoke. + */ + private async handleDialogSubmit( + ctx: IActivityContext + ): Promise { + if (!this.chat) { + return undefined; + } + + const activity = ctx.activity; + const data = (activity.value?.data || {}) as Record; + const { contextId, callbackId, values } = parseDialogSubmitValues(data); + + const event = { + callbackId: callbackId || "", + viewId: activity.id || "", + values, + privateMetadata: undefined, + user: { + userId: activity.from?.id || "unknown", + userName: activity.from?.name || "unknown", + fullName: activity.from?.name || "unknown", + isBot: false, + isMe: false, + }, + adapter: this, + raw: activity, + }; + + this.logger.debug("Processing Teams dialog.submit", { + callbackId, + contextId, + }); + + const response = await this.chat.processModalSubmit(event, contextId); + return modalResponseToTaskModuleResponse(response, this.logger, contextId); + } + /** * Handle Teams reaction events. */ diff --git a/packages/adapter-teams/src/modals.test.ts b/packages/adapter-teams/src/modals.test.ts new file mode 100644 index 00000000..a5ff42fa --- /dev/null +++ b/packages/adapter-teams/src/modals.test.ts @@ -0,0 +1,311 @@ +import type { + ModalCloseResponse, + ModalElement, + ModalErrorsResponse, + ModalPushResponse, + ModalUpdateResponse, +} from "chat"; +import { + CardText, + Field, + Fields, + Modal, + RadioSelect, + Select, + SelectOption, + TextInput, +} from "chat"; +import { describe, expect, it, vi } from "vitest"; +import { + modalResponseToTaskModuleResponse, + modalToAdaptiveCard, + parseDialogSubmitValues, +} from "./modals"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeModal(overrides: Partial = {}): ModalElement { + return Modal({ + callbackId: "cb-1", + title: "Test Modal", + ...overrides, + children: overrides.children ?? [], + }); +} + +// --------------------------------------------------------------------------- +// modalToAdaptiveCard +// --------------------------------------------------------------------------- + +describe("modalToAdaptiveCard", () => { + it("produces a valid Adaptive Card structure", () => { + const card = modalToAdaptiveCard(makeModal(), "ctx-1", "cb-1"); + + expect(card.type).toBe("AdaptiveCard"); + expect(card.$schema).toBe( + "http://adaptivecards.io/schemas/adaptive-card.json" + ); + expect(card.version).toBe("1.4"); + expect(card.body).toBeInstanceOf(Array); + }); + + it("includes contextId and callbackId in submit action data", () => { + const card = modalToAdaptiveCard(makeModal(), "ctx-1", "cb-1"); + + expect(card.actions).toHaveLength(1); + const action = card.actions[0] as { data: Record }; + expect(action.data.__contextId).toBe("ctx-1"); + expect(action.data.__callbackId).toBe("cb-1"); + }); + + it("uses custom submitLabel when provided", () => { + const modal = makeModal({ submitLabel: "Send it" }); + const card = modalToAdaptiveCard(modal, "ctx-1", "cb-1"); + + const action = card.actions[0] as { title: string }; + expect(action.title).toBe("Send it"); + }); + + it("defaults submitLabel to 'Submit'", () => { + const card = modalToAdaptiveCard(makeModal(), "ctx-1", "cb-1"); + + const action = card.actions[0] as { title: string }; + expect(action.title).toBe("Submit"); + }); +}); + +// --------------------------------------------------------------------------- +// Child element conversion (through modalToAdaptiveCard) +// --------------------------------------------------------------------------- + +describe("modal child element conversion", () => { + it("converts text_input to TextInput", () => { + const modal = makeModal({ + children: [ + TextInput({ + id: "name", + label: "Your Name", + placeholder: "Enter name", + }), + ], + }); + const card = modalToAdaptiveCard(modal, "ctx", "cb"); + + expect(card.body).toHaveLength(1); + expect(card.body[0]).toMatchObject({ + type: "Input.Text", + id: "name", + label: "Your Name", + placeholder: "Enter name", + isRequired: true, + isMultiline: false, + }); + }); + + it("converts select to ChoiceSetInput with compact style", () => { + const modal = makeModal({ + children: [ + Select({ + id: "color", + label: "Favorite Color", + options: [ + SelectOption({ label: "Red", value: "red" }), + SelectOption({ label: "Blue", value: "blue" }), + ], + }), + ], + }); + const card = modalToAdaptiveCard(modal, "ctx", "cb"); + + expect(card.body).toHaveLength(1); + expect(card.body[0]).toMatchObject({ + type: "Input.ChoiceSet", + id: "color", + label: "Favorite Color", + style: "compact", + isRequired: true, + }); + const choiceSet = card.body[0] as { + choices: { title: string; value: string }[]; + }; + expect(choiceSet.choices).toHaveLength(2); + expect(choiceSet.choices[0]).toMatchObject({ title: "Red", value: "red" }); + }); + + it("converts radio_select to ChoiceSetInput with expanded style", () => { + const modal = makeModal({ + children: [ + RadioSelect({ + id: "size", + label: "Size", + options: [ + SelectOption({ label: "Small", value: "sm" }), + SelectOption({ label: "Large", value: "lg" }), + ], + }), + ], + }); + const card = modalToAdaptiveCard(modal, "ctx", "cb"); + + expect(card.body).toHaveLength(1); + expect(card.body[0]).toMatchObject({ + type: "Input.ChoiceSet", + id: "size", + label: "Size", + style: "expanded", + isRequired: true, + }); + }); + + it("converts text to TextBlock with style support", () => { + const modal = makeModal({ + children: [ + CardText("Hello"), + CardText("Bold text", { style: "bold" }), + CardText("Muted text", { style: "muted" }), + ], + }); + const card = modalToAdaptiveCard(modal, "ctx", "cb"); + + expect(card.body).toHaveLength(3); + expect(card.body[0]).toMatchObject({ + type: "TextBlock", + text: "Hello", + wrap: true, + }); + expect(card.body[1]).toMatchObject({ + type: "TextBlock", + weight: "Bolder", + }); + expect(card.body[2]).toMatchObject({ + type: "TextBlock", + isSubtle: true, + }); + }); + + it("converts fields to FactSet", () => { + const modal = makeModal({ + children: [ + Fields([ + Field({ label: "Name", value: "Alice" }), + Field({ label: "Role", value: "Engineer" }), + ]), + ], + }); + const card = modalToAdaptiveCard(modal, "ctx", "cb"); + + expect(card.body).toHaveLength(1); + expect(card.body[0]).toMatchObject({ type: "FactSet" }); + const factSet = card.body[0] as { + facts: { title: string; value: string }[]; + }; + expect(factSet.facts).toHaveLength(2); + expect(factSet.facts[0]).toMatchObject({ title: "Name", value: "Alice" }); + }); +}); + +// --------------------------------------------------------------------------- +// parseDialogSubmitValues +// --------------------------------------------------------------------------- + +describe("parseDialogSubmitValues", () => { + it("extracts callbackId, contextId, and user values", () => { + const result = parseDialogSubmitValues({ + __contextId: "ctx-1", + __callbackId: "cb-1", + msteams: { some: "data" }, + name: "Alice", + color: "blue", + }); + + expect(result.contextId).toBe("ctx-1"); + expect(result.callbackId).toBe("cb-1"); + expect(result.values).toEqual({ name: "Alice", color: "blue" }); + }); + + it("returns empty result for undefined data", () => { + const result = parseDialogSubmitValues(undefined); + + expect(result.contextId).toBeUndefined(); + expect(result.callbackId).toBeUndefined(); + expect(result.values).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// modalResponseToTaskModuleResponse +// --------------------------------------------------------------------------- + +describe("modalResponseToTaskModuleResponse", () => { + it("returns undefined for undefined response", () => { + expect(modalResponseToTaskModuleResponse(undefined)).toBeUndefined(); + }); + + it("returns undefined for close action", () => { + const response: ModalCloseResponse = { action: "close" }; + expect(modalResponseToTaskModuleResponse(response)).toBeUndefined(); + }); + + it("returns continue response for update action", () => { + const modal = makeModal({ title: "Updated" }); + const response: ModalUpdateResponse = { action: "update", modal }; + const result = modalResponseToTaskModuleResponse( + response, + undefined, + "ctx-1" + ); + + expect(result).toBeDefined(); + expect(result?.task).toMatchObject({ + type: "continue", + value: { + title: "Updated", + card: { + contentType: "application/vnd.microsoft.card.adaptive", + }, + }, + }); + }); + + it("falls back to continue and warns for push action", () => { + const modal = makeModal({ title: "Pushed" }); + const response: ModalPushResponse = { action: "push", modal }; + const logger = { warn: vi.fn() }; + const result = modalResponseToTaskModuleResponse(response, logger, "ctx-1"); + + expect(result).toBeDefined(); + expect(result?.task).toMatchObject({ type: "continue" }); + expect(logger.warn).toHaveBeenCalledOnce(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("does not support dialog stacking"), + expect.any(Object) + ); + }); + + it("returns error card for errors action", () => { + const response: ModalErrorsResponse = { + action: "errors", + errors: { name: "Required", email: "Invalid format" }, + }; + const result = modalResponseToTaskModuleResponse(response); + + expect(result).toBeDefined(); + expect(result?.task).toMatchObject({ + type: "continue", + value: { + title: "Validation Error", + card: { + contentType: "application/vnd.microsoft.card.adaptive", + }, + }, + }); + + const card = result?.task.value.card.content as { + body: { text: string }[]; + }; + expect(card.body.length).toBeGreaterThanOrEqual(3); + expect(card.body[0].text).toContain("Please fix"); + }); +}); diff --git a/packages/adapter-teams/src/modals.ts b/packages/adapter-teams/src/modals.ts new file mode 100644 index 00000000..8eed0800 --- /dev/null +++ b/packages/adapter-teams/src/modals.ts @@ -0,0 +1,310 @@ +/** + * Teams dialog (task module) converter. + * Converts ModalElement to Adaptive Card JSON for Teams task modules, + * and converts ModalResponse to TaskModuleResponse format. + */ + +import { createEmojiConverter, mapButtonStyle } from "@chat-adapter/shared"; +import type { TaskModuleResponse } from "@microsoft/teams.api"; +import type { + ActionStyle, + CardElementArray, + ChoiceSetInputOptions, + TextInputOptions, +} from "@microsoft/teams.cards"; +import { + AdaptiveCard, + Choice, + ChoiceSetInput, + Fact, + FactSet, + SubmitAction, + TextBlock, + TextInput, +} from "@microsoft/teams.cards"; +import type { + FieldsElement, + ModalChild, + ModalElement, + ModalResponse, + RadioSelectElement, + SelectElement, + TextElement, + TextInputElement, +} from "chat"; + +const convertEmoji = createEmojiConverter("teams"); + +const ADAPTIVE_CARD_SCHEMA = + "http://adaptivecards.io/schemas/adaptive-card.json"; +const ADAPTIVE_CARD_VERSION = "1.4" as const; + +// ============================================================================ +// ModalElement -> Adaptive Card +// ============================================================================ + +/** + * Convert a ModalElement to an Adaptive Card for use inside a Teams task module. + * + * @param modal - The modal element to convert + * @param contextId - Context ID for server-side stored thread/message context + * @param callbackId - Callback ID for routing the submit event + */ +export function modalToAdaptiveCard( + modal: ModalElement, + contextId: string, + callbackId: string +): AdaptiveCard { + const body: CardElementArray = []; + + for (const child of modal.children) { + body.push(...modalChildToAdaptiveElements(child)); + } + + const submitData: Record = { + __contextId: contextId, + __callbackId: callbackId, + }; + + const submitOptions: { + title: string; + data: Record; + style?: ActionStyle; + } = { + title: modal.submitLabel || "Submit", + data: submitData, + }; + + const style = mapButtonStyle("primary", "teams") as ActionStyle | undefined; + if (style) { + submitOptions.style = style; + } + + const submitAction = new SubmitAction(submitOptions); + + return new AdaptiveCard(...body) + .withOptions({ + $schema: ADAPTIVE_CARD_SCHEMA, + version: ADAPTIVE_CARD_VERSION, + }) + .withActions(submitAction); +} + +function modalChildToAdaptiveElements(child: ModalChild): CardElementArray { + switch (child.type) { + case "text_input": + return [textInputToAdaptive(child)]; + case "select": + return [selectToAdaptive(child)]; + case "radio_select": + return [radioSelectToAdaptive(child)]; + case "text": + return [textToAdaptive(child)]; + case "fields": + return [fieldsToAdaptive(child)]; + default: + return []; + } +} + +function textInputToAdaptive(input: TextInputElement): TextInput { + const options: TextInputOptions = { + id: input.id, + label: convertEmoji(input.label), + isMultiline: input.multiline ?? false, + isRequired: !(input.optional ?? false), + placeholder: input.placeholder, + value: input.initialValue, + maxLength: input.maxLength, + }; + + return new TextInput(options); +} + +function selectToAdaptive(select: SelectElement): ChoiceSetInput { + const choices = select.options.map( + (opt) => new Choice({ title: convertEmoji(opt.label), value: opt.value }) + ); + + const options: ChoiceSetInputOptions = { + id: select.id, + label: convertEmoji(select.label), + style: "compact", + isRequired: !(select.optional ?? false), + placeholder: select.placeholder, + value: select.initialOption, + }; + + return new ChoiceSetInput(...choices).withOptions(options); +} + +function radioSelectToAdaptive( + radioSelect: RadioSelectElement +): ChoiceSetInput { + const choices = radioSelect.options.map( + (opt) => new Choice({ title: convertEmoji(opt.label), value: opt.value }) + ); + + const options: ChoiceSetInputOptions = { + id: radioSelect.id, + label: convertEmoji(radioSelect.label), + style: "expanded", + isRequired: !(radioSelect.optional ?? false), + value: radioSelect.initialOption, + }; + + return new ChoiceSetInput(...choices).withOptions(options); +} + +function textToAdaptive(text: TextElement): TextBlock { + const options: { wrap: boolean; weight?: "Bolder"; isSubtle?: boolean } = { + wrap: true, + }; + + if (text.style === "bold") { + options.weight = "Bolder"; + } else if (text.style === "muted") { + options.isSubtle = true; + } + + return new TextBlock(convertEmoji(text.content), options); +} + +function fieldsToAdaptive(fields: FieldsElement): FactSet { + const facts = fields.children.map( + (field) => new Fact(convertEmoji(field.label), convertEmoji(field.value)) + ); + + return new FactSet(...facts); +} + +// ============================================================================ +// Dialog submit value parsing +// ============================================================================ + +export interface DialogSubmitValues { + callbackId: string | undefined; + contextId: string | undefined; + values: Record; +} + +/** + * Extract user input values from an Action.Submit data payload, + * stripping out internal keys (__contextId, __callbackId, msteams). + */ +export function parseDialogSubmitValues( + data: Record | undefined +): DialogSubmitValues { + if (!data) { + return { contextId: undefined, callbackId: undefined, values: {} }; + } + + const contextId = data.__contextId as string | undefined; + const callbackId = data.__callbackId as string | undefined; + + const values: Record = {}; + for (const [key, val] of Object.entries(data)) { + if (key === "__contextId" || key === "__callbackId" || key === "msteams") { + continue; + } + if (typeof val === "string") { + values[key] = val; + } + } + + return { contextId, callbackId, values }; +} + +// ============================================================================ +// ModalResponse -> Teams task module response +// ============================================================================ + +function buildContinueResponse( + modal: ModalElement, + contextId?: string +): TaskModuleResponse { + const card = modalToAdaptiveCard(modal, contextId || "", modal.callbackId); + return { + task: { + type: "continue" as const, + value: { + title: modal.title, + card: { + contentType: "application/vnd.microsoft.card.adaptive", + content: card, + }, + }, + }, + }; +} + +/** + * Convert a ModalResponse from the handler into a Teams task module response. + * Returns undefined to signal "close dialog" (empty HTTP body). + * + * @param response - The modal response from the submit handler + * @param logger - Optional logger for warnings + */ +export function modalResponseToTaskModuleResponse( + response: ModalResponse | undefined, + logger?: { warn: (msg: string, meta?: Record) => void }, + contextId?: string +): TaskModuleResponse | undefined { + if (!response) { + return undefined; + } + switch (response.action) { + case "close": + // undefined signals "close dialog" (empty HTTP body) + return undefined; + + case "update": + return buildContinueResponse(response.modal, contextId); + + case "push": + // Teams has no dialog stacking — fall back to update with a warning + logger?.warn( + "Teams does not support dialog stacking (push). Falling back to update.", + { callbackId: response.modal.callbackId } + ); + return buildContinueResponse(response.modal, contextId); + + case "errors": { + // Render a simple error card listing validation issues + const errorBlocks = Object.entries(response.errors).map( + ([field, msg]) => + new TextBlock(`**${field}**: ${msg}`, { + wrap: true, + color: "Attention", + }) + ); + + const errorCard = new AdaptiveCard( + new TextBlock("Please fix the following errors:", { + weight: "Bolder", + wrap: true, + }), + ...errorBlocks + ).withOptions({ + $schema: ADAPTIVE_CARD_SCHEMA, + version: ADAPTIVE_CARD_VERSION, + }); + + return { + task: { + type: "continue" as const, + value: { + title: "Validation Error", + card: { + contentType: "application/vnd.microsoft.card.adaptive", + content: errorCard, + }, + }, + }, + }; + } + + default: + return undefined; + } +} diff --git a/packages/adapter-teams/src/types.ts b/packages/adapter-teams/src/types.ts index 8d0eaa56..22c1d167 100644 --- a/packages/adapter-teams/src/types.ts +++ b/packages/adapter-teams/src/types.ts @@ -28,6 +28,8 @@ export interface TeamsAdapterConfig { appType?: "MultiTenant" | "SingleTenant"; /** @deprecated Certificate auth is not yet supported by the Teams SDK. Throws at startup. */ certificate?: TeamsAuthCertificate; + /** Timeout in ms for the handler to call openModal() after a task/fetch trigger. Defaults to 5000. */ + dialogOpenTimeoutMs?: number; /** Federated (workload identity) authentication. Maps to managedIdentityClientId in the Teams SDK. */ federated?: TeamsAuthFederated; /** Logger instance for error reporting. Defaults to ConsoleLogger. */ diff --git a/packages/chat/src/cards.ts b/packages/chat/src/cards.ts index ea6fc0c3..ab31a259 100644 --- a/packages/chat/src/cards.ts +++ b/packages/chat/src/cards.ts @@ -59,6 +59,8 @@ export type TextStyle = "plain" | "bold" | "muted"; /** Button element for interactive actions */ export interface ButtonElement { + /** Whether this button triggers a regular action or opens a modal dialog. Default: "action" */ + actionType?: "action" | "modal"; /** If true, the button is displayed in an inactive state and doesn't respond to user actions */ disabled?: boolean; /** Unique action ID for callback routing */ @@ -350,6 +352,8 @@ export function Actions( /** Options for Button */ export interface ButtonOptions { + /** Whether this button triggers a regular action or opens a modal dialog. Default: "action" */ + actionType?: "action" | "modal"; /** If true, the button is displayed in an inactive state and doesn't respond to user actions */ disabled?: boolean; /** Unique action ID for callback routing */ @@ -379,6 +383,7 @@ export function Button(options: ButtonOptions): ButtonElement { style: options.style, value: options.value, disabled: options.disabled, + actionType: options.actionType, }; } @@ -650,13 +655,15 @@ export function fromReactElement(element: unknown): AnyCardElement | null { ); case "Button": { - // JSX: + // JSX: const label = extractTextContent(props.children); return Button({ id: props.id as string, label: (props.label as string | undefined) ?? label, style: props.style as ButtonStyle | undefined, value: props.value as string | undefined, + actionType: props.actionType as "action" | "modal" | undefined, + disabled: props.disabled as boolean | undefined, }); } diff --git a/packages/chat/src/chat.test.ts b/packages/chat/src/chat.test.ts index 2930d50b..578311d2 100644 --- a/packages/chat/src/chat.test.ts +++ b/packages/chat/src/chat.test.ts @@ -961,7 +961,7 @@ describe("Chat", () => { raw: {}, }; - chat.processAction(event); + chat.processAction(event, undefined); await new Promise((r) => setTimeout(r, 10)); expect(handler).toHaveBeenCalled(); @@ -1005,8 +1005,8 @@ describe("Chat", () => { raw: {}, }; - chat.processAction(approveEvent); - chat.processAction(skipEvent); + chat.processAction(approveEvent, undefined); + chat.processAction(skipEvent, undefined); await new Promise((r) => setTimeout(r, 10)); expect(handler).toHaveBeenCalledTimes(1); @@ -1033,7 +1033,7 @@ describe("Chat", () => { raw: {}, }; - chat.processAction(event); + chat.processAction(event, undefined); await new Promise((r) => setTimeout(r, 10)); expect(handler).toHaveBeenCalled(); @@ -1058,7 +1058,7 @@ describe("Chat", () => { raw: {}, }; - chat.processAction(event); + chat.processAction(event, undefined); await new Promise((r) => setTimeout(r, 10)); expect(handler).not.toHaveBeenCalled(); @@ -1083,7 +1083,7 @@ describe("Chat", () => { raw: {}, }; - chat.processAction(event); + chat.processAction(event, undefined); await new Promise((r) => setTimeout(r, 10)); expect(handler).toHaveBeenCalled(); @@ -1114,7 +1114,7 @@ describe("Chat", () => { raw: {}, }; - chat.processAction(event); + chat.processAction(event, undefined); await new Promise((r) => setTimeout(r, 20)); expect(handler).toHaveBeenCalled(); @@ -1147,7 +1147,7 @@ describe("Chat", () => { triggerId: "trigger-123", }; - chat.processAction(event); + chat.processAction(event, undefined); await new Promise((r) => setTimeout(r, 10)); expect(handler).toHaveBeenCalled(); @@ -1207,7 +1207,7 @@ describe("Chat", () => { triggerId: "trigger-123", }; - chat.processAction(event); + chat.processAction(event, undefined); await new Promise((r) => setTimeout(r, 10)); // Call openModal with a JSX Modal element @@ -1255,7 +1255,7 @@ describe("Chat", () => { // No triggerId }; - chat.processAction(event); + chat.processAction(event, undefined); await new Promise((r) => setTimeout(r, 10)); expect(handler).toHaveBeenCalled(); @@ -1304,7 +1304,7 @@ describe("Chat", () => { triggerId: "trigger-123", }; - chat.processAction(event); + chat.processAction(event, undefined); await new Promise((r) => setTimeout(r, 10)); expect(handler).toHaveBeenCalled(); @@ -1347,7 +1347,7 @@ describe("Chat", () => { triggerId: "trigger-456", }; - chat.processAction(event); + chat.processAction(event, undefined); await new Promise((r) => setTimeout(r, 10)); expect(handler).toHaveBeenCalled(); @@ -2075,7 +2075,7 @@ describe("Chat", () => { triggerId: "trigger-action-123", }; - chat.processAction(actionEvent); + chat.processAction(actionEvent, undefined); await new Promise((r) => setTimeout(r, 10)); expect(actionHandler).toHaveBeenCalled(); diff --git a/packages/chat/src/chat.ts b/packages/chat/src/chat.ts index ca93ac1d..e5990dcd 100644 --- a/packages/chat/src/chat.ts +++ b/packages/chat/src/chat.ts @@ -827,9 +827,9 @@ export class Chat< */ processAction( event: Omit & { adapter: Adapter }, - options?: WebhookOptions - ): void { - const task = this.handleActionEvent(event).catch((err) => { + options: WebhookOptions | undefined + ): Promise { + const task = this.handleActionEvent(event, options).catch((err) => { this.logger.error("Action processing error", { error: err, actionId: event.actionId, @@ -840,6 +840,8 @@ export class Chat< if (options?.waitUntil) { options.waitUntil(task); } + + return task; } async processModalSubmit( @@ -925,9 +927,9 @@ export class Chat< adapter: Adapter; channelId: string; }, - options?: WebhookOptions + options: WebhookOptions | undefined ): void { - const task = this.handleSlashCommandEvent(event).catch((err) => { + const task = this.handleSlashCommandEvent(event, options).catch((err) => { this.logger.error("Slash command processing error", { error: err, command: event.command, @@ -1028,7 +1030,8 @@ export class Chat< event: Omit & { adapter: Adapter; channelId: string; - } + }, + options: WebhookOptions | undefined ): Promise { this.logger.debug("Incoming slash command", { adapter: event.adapter.name, @@ -1051,11 +1054,12 @@ export class Chat< ...event, channel, openModal: async (modal) => { - if (!event.triggerId) { + // Allow if onOpenModal is provided OR triggerId exists + if (!(event.triggerId || options?.onOpenModal)) { this.logger.warn("Cannot open modal: no triggerId available"); return undefined; } - if (!event.adapter.openModal) { + if (!(options?.onOpenModal || event.adapter.openModal)) { this.logger.warn( `Cannot open modal: ${event.adapter.name} does not support modals` ); @@ -1070,18 +1074,26 @@ export class Chat< modalElement = converted; } const contextId = crypto.randomUUID(); - this.storeModalContext( + await this.storeModalContext( event.adapter.name, contextId, undefined, undefined, channel ); - return event.adapter.openModal( - event.triggerId, - modalElement, - contextId - ); + + // Use hook if provided, otherwise fall back to adapter.openModal + if (options?.onOpenModal) { + return options.onOpenModal(modalElement, contextId); + } + if (event.triggerId && event.adapter.openModal) { + return event.adapter.openModal( + event.triggerId, + modalElement, + contextId + ); + } + return undefined; }, }; this.logger.debug("Checking slash command handlers", { @@ -1107,25 +1119,27 @@ export class Chat< * Store modal context server-side with a context ID. * Called when opening a modal to preserve thread/message/channel for the submit handler. */ - private storeModalContext( + private async storeModalContext( adapterName: string, contextId: string, thread?: ThreadImpl, message?: Message, channel?: ChannelImpl - ): void { + ): Promise { const key = `modal-context:${adapterName}:${contextId}`; const context: StoredModalContext = { thread: thread?.toJSON(), message: message?.toJSON(), channel: channel?.toJSON(), }; - this._stateAdapter.set(key, context, MODAL_CONTEXT_TTL_MS).catch((err) => { + try { + await this._stateAdapter.set(key, context, MODAL_CONTEXT_TTL_MS); + } catch (err) { this.logger.error("Failed to store modal context", { contextId, error: err, }); - }); + } } /** @@ -1159,6 +1173,9 @@ export class Chat< }; } + // Clean up stored context after retrieval (one-time use) + await this._stateAdapter.delete(key); + const adapter = this.adapters.get(adapterName); // Reconstruct thread with adapter directly (if present) @@ -1189,7 +1206,8 @@ export class Chat< * Handle an action event internally. */ private async handleActionEvent( - event: Omit & { adapter: Adapter } + event: Omit & { adapter: Adapter }, + options: WebhookOptions | undefined ): Promise { this.logger.debug("Incoming action", { adapter: event.adapter.name, @@ -1237,11 +1255,12 @@ export class Chat< ...event, thread, openModal: async (modal) => { - if (!event.triggerId) { + // Allow if onOpenModal is provided OR triggerId exists + if (!(event.triggerId || options?.onOpenModal)) { this.logger.warn("Cannot open modal: no triggerId available"); return undefined; } - if (!event.adapter.openModal) { + if (!(options?.onOpenModal || event.adapter.openModal)) { this.logger.warn( `Cannot open modal: ${event.adapter.name} does not support modals` ); @@ -1285,18 +1304,26 @@ export class Chat< const channel = thread ? ((thread as ThreadImpl).channel as ChannelImpl) : undefined; - this.storeModalContext( + await this.storeModalContext( event.adapter.name, contextId, thread ? (thread as ThreadImpl) : undefined, message, channel ); - return event.adapter.openModal( - event.triggerId, - modalElement, - contextId - ); + + // Use hook if provided, otherwise fall back to adapter.openModal + if (options?.onOpenModal) { + return options.onOpenModal(modalElement, contextId); + } + if (event.triggerId && event.adapter.openModal) { + return event.adapter.openModal( + event.triggerId, + modalElement, + contextId + ); + } + return undefined; }, }; diff --git a/packages/chat/src/jsx-runtime.ts b/packages/chat/src/jsx-runtime.ts index 52e23e12..aa6a88da 100644 --- a/packages/chat/src/jsx-runtime.ts +++ b/packages/chat/src/jsx-runtime.ts @@ -105,7 +105,9 @@ export interface TextProps { /** Props for Button component in JSX */ export interface ButtonProps { + actionType?: "action" | "modal"; children?: string | number | (string | number | undefined)[]; + disabled?: boolean; id: string; label?: string; style?: ButtonStyle; @@ -589,6 +591,8 @@ function resolveJSXElement(element: JSXElement): AnyCardElement { label, style: props.style, value: props.value, + actionType: props.actionType, + disabled: props.disabled, }); } diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 0076d3c7..57676776 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -146,6 +146,19 @@ export interface ChatConfig< * Options for webhook handling. */ export interface WebhookOptions { + /** + * Override the default modal-opening behavior to handle it inline + * within the current webhook response cycle. + * When provided, called instead of adapter.openModal(). + * Used by Teams to return modal content in the HTTP invoke response. + * + * The returned `viewId` is platform-specific (e.g. Slack's view ID). + * Adapters that don't produce a view ID may return void. + */ + onOpenModal?: ( + modal: ModalElement, + contextId: string + ) => Promise<{ viewId: string } | undefined>; /** * Function to run message handling in the background. * Use this to ensure fast webhook responses while processing continues. @@ -526,8 +539,8 @@ export interface ChatInstance { */ processAction( event: Omit & { adapter: Adapter }, - options?: WebhookOptions - ): void; + options: WebhookOptions | undefined + ): Promise; processAppHomeOpened( event: AppHomeOpenedEvent, @@ -620,7 +633,7 @@ export interface ChatInstance { adapter: Adapter; channelId: string; }, - options?: WebhookOptions + options: WebhookOptions | undefined ): void; } @@ -1957,7 +1970,7 @@ export type ModalResponse = export type ModalSubmitHandler = ( event: ModalSubmitEvent // biome-ignore lint/suspicious/noConfusingVoidType: void is needed for sync handlers that return nothing -) => void | Promise; +) => void | Promise; export type ModalCloseHandler = ( event: ModalCloseEvent diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b80f0e7..52d206dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -412,6 +412,9 @@ importers: '@microsoft/teams.apps': specifier: ^2.0.6 version: 2.0.6 + '@microsoft/teams.cards': + specifier: ^2.0.6 + version: 2.0.6 '@microsoft/teams.graph-endpoints': specifier: ^2.0.6 version: 2.0.6