diff --git a/extensions/msteams/src/monitor-handler.adaptive-card.test.ts b/extensions/msteams/src/monitor-handler.adaptive-card.test.ts index 4df9857ca8cd..0f1f7bcb7b71 100644 --- a/extensions/msteams/src/monitor-handler.adaptive-card.test.ts +++ b/extensions/msteams/src/monitor-handler.adaptive-card.test.ts @@ -94,12 +94,57 @@ function createActivityHandler() { return { handler, run }; } +async function runAdaptiveCardInvoke( + registered: MSTeamsActivityHandler & { + run: NonNullable; + }, + value: unknown, +) { + await registered.run({ + activity: { + id: "invoke-1", + type: "invoke", + name: "adaptiveCard/action", + channelId: "msteams", + serviceUrl: "https://service.example.test", + from: { + id: "user-bf", + aadObjectId: "user-aad", + name: "User", + }, + recipient: { + id: "bot-id", + name: "Bot", + }, + conversation: { + id: "19:personal-chat;messageid=abc123", + conversationType: "personal", + }, + channelData: {}, + attachments: [], + value, + }, + sendActivity: vi.fn(async () => ({ id: "activity-id" })), + sendActivities: async () => [], + } as unknown as MSTeamsTurnContext); +} + +function lastDispatchedCtxPayload(): Record { + const dispatched = runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock.calls.at( + -1, + )?.[0] as { ctxPayload?: Record } | undefined; + if (!dispatched?.ctxPayload) { + throw new Error("expected dispatched context payload"); + } + return dispatched.ctxPayload; +} + describe("msteams adaptive card action invoke", () => { beforeEach(() => { runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mockClear(); }); - it("forwards adaptive card invoke values to the agent as message text", async () => { + it("forwards adaptive card submitted data to the agent as message text", async () => { const deps = createDeps(); const { handler, run } = createActivityHandler(); const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & { @@ -116,44 +161,117 @@ describe("msteams adaptive card action invoke", () => { trigger: "button-click", }; - await registered.run({ - activity: { - id: "invoke-1", - type: "invoke", - name: "adaptiveCard/action", - channelId: "msteams", - serviceUrl: "https://service.example.test", - from: { - id: "user-bf", - aadObjectId: "user-aad", - name: "User", - }, - recipient: { - id: "bot-id", - name: "Bot", - }, - conversation: { - id: "19:personal-chat;messageid=abc123", - conversationType: "personal", - }, - channelData: {}, - attachments: [], - value: payload, - }, - sendActivity: vi.fn(async () => ({ id: "activity-id" })), - sendActivities: async () => [], - } as unknown as MSTeamsTurnContext); + await runAdaptiveCardInvoke(registered, payload); expect(run).not.toHaveBeenCalled(); expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).toHaveBeenCalledTimes( 1, ); - const dispatched = runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock - .calls[0]?.[0] as { ctxPayload?: Record } | undefined; - expect(dispatched?.ctxPayload?.RawBody).toBe(JSON.stringify(payload)); - expect(dispatched?.ctxPayload?.BodyForAgent).toBe(JSON.stringify(payload)); - expect(dispatched?.ctxPayload?.CommandBody).toBe(JSON.stringify(payload)); - expect(dispatched?.ctxPayload?.SessionKey).toBe("msteams:direct:user-aad"); - expect(dispatched?.ctxPayload?.SenderId).toBe("user-aad"); + const expectedBody = JSON.stringify(payload.action.data); + const ctxPayload = lastDispatchedCtxPayload(); + expect(ctxPayload.RawBody).toBe(expectedBody); + expect(ctxPayload.BodyForAgent).toBe(expectedBody); + expect(ctxPayload.CommandBody).toBe(expectedBody); + expect(ctxPayload.SessionKey).toBe("msteams:direct:user-aad"); + expect(ctxPayload.SenderId).toBe("user-aad"); + }); + + it("routes Teams imBack actions as the submitted message text", async () => { + const deps = createDeps(); + const { handler } = createActivityHandler(); + const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & { + run: NonNullable; + }; + + await runAdaptiveCardInvoke(registered, { + action: { + type: "Action.Submit", + data: { msteams: { type: "imBack", value: "Summarize my last meeting" } }, + }, + }); + + const ctxPayload = lastDispatchedCtxPayload(); + expect(ctxPayload.BodyForAgent).toBe("Summarize my last meeting"); + expect(ctxPayload.CommandBody).toBe("Summarize my last meeting"); + }); + + it("routes typed command submit actions as command text", async () => { + const deps = createDeps(); + const { handler } = createActivityHandler(); + const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & { + run: NonNullable; + }; + + await runAdaptiveCardInvoke(registered, { + action: { + type: "Action.Submit", + data: "/codex plugins menu", + }, + }); + + const ctxPayload = lastDispatchedCtxPayload(); + expect(ctxPayload.BodyForAgent).toBe("/codex plugins menu"); + expect(ctxPayload.CommandBody).toBe("/codex plugins menu"); + }); + + it("preserves legacy presentation submit values as structured data", async () => { + const deps = createDeps(); + const { handler } = createActivityHandler(); + const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & { + run: NonNullable; + }; + const data = { value: "/codex permissions yolo", label: "Run" }; + + await runAdaptiveCardInvoke(registered, { + action: { + type: "Action.Submit", + data, + }, + }); + + const ctxPayload = lastDispatchedCtxPayload(); + expect(ctxPayload.BodyForAgent).toBe(JSON.stringify(data)); + expect(ctxPayload.CommandBody).toBe(JSON.stringify(data)); + }); + + it("preserves arbitrary submitted data with a value field", async () => { + const deps = createDeps(); + const { handler } = createActivityHandler(); + const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & { + run: NonNullable; + }; + const data = { value: "selected", formId: "deploy-approval", choices: ["canary"] }; + + await runAdaptiveCardInvoke(registered, { + action: { + type: "Action.Submit", + data, + }, + }); + + const ctxPayload = lastDispatchedCtxPayload(); + expect(ctxPayload.BodyForAgent).toBe(JSON.stringify(data)); + expect(ctxPayload.CommandBody).toBe(JSON.stringify(data)); + }); + + it("preserves generic Action.Execute verb metadata", async () => { + const deps = createDeps(); + const { handler } = createActivityHandler(); + const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & { + run: NonNullable; + }; + const payload = { + action: { + type: "Action.Execute", + verb: "ticket.approve", + data: { ticketId: "ticket-123" }, + }, + }; + + await runAdaptiveCardInvoke(registered, payload); + + const ctxPayload = lastDispatchedCtxPayload(); + expect(ctxPayload.BodyForAgent).toBe(JSON.stringify(payload)); + expect(ctxPayload.CommandBody).toBe(JSON.stringify(payload)); }); }); diff --git a/extensions/msteams/src/monitor-handler.ts b/extensions/msteams/src/monitor-handler.ts index 13122d474e25..354f8a8ecf15 100644 --- a/extensions/msteams/src/monitor-handler.ts +++ b/extensions/msteams/src/monitor-handler.ts @@ -1,5 +1,9 @@ // Msteams plugin module implements monitor handler behavior. -import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + isRecord, + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatUnknownError } from "./errors.js"; import { resolveMSTeamsSenderAccess } from "./monitor-handler/access.js"; import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js"; @@ -25,16 +29,43 @@ export type MSTeamsActivityHandler = { run?: (context: unknown) => Promise; }; +function extractAdaptiveCardSubmittedData(value: unknown): unknown { + if (!isRecord(value)) { + return value; + } + const action = isRecord(value.action) ? value.action : undefined; + if (action && normalizeOptionalLowercaseString(action.type) === "action.submit" && "data" in action) { + return action.data; + } + return value; +} + +function readMSTeamsImBackValue(value: unknown): string | null { + if (!isRecord(value)) { + return null; + } + const msteams = isRecord(value.msteams) ? value.msteams : undefined; + if (!msteams || normalizeOptionalLowercaseString(msteams.type) !== "imback") { + return null; + } + return normalizeOptionalString(msteams.value) ?? null; +} + function serializeAdaptiveCardActionValue(value: unknown): string | null { - if (typeof value === "string") { - const trimmed = value.trim(); + const submittedValue = extractAdaptiveCardSubmittedData(value); + if (typeof submittedValue === "string") { + const trimmed = submittedValue.trim(); return trimmed ? trimmed : null; } - if (value === undefined) { + const imBackValue = readMSTeamsImBackValue(submittedValue); + if (imBackValue) { + return imBackValue; + } + if (submittedValue == null) { return null; } try { - return JSON.stringify(value); + return JSON.stringify(submittedValue); } catch { return null; }