fix(msteams): unwrap adaptive card submit data

This commit is contained in:
Vincent Koc
2026-06-17 10:36:18 +02:00
parent 30955d3660
commit 582c2d41b9
2 changed files with 189 additions and 40 deletions

View File

@@ -94,12 +94,57 @@ function createActivityHandler() {
return { handler, run };
}
async function runAdaptiveCardInvoke(
registered: MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
},
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<string, unknown> {
const dispatched = runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock.calls.at(
-1,
)?.[0] as { ctxPayload?: Record<string, unknown> } | 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<string, unknown> } | 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<MSTeamsActivityHandler["run"]>;
};
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<MSTeamsActivityHandler["run"]>;
};
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<MSTeamsActivityHandler["run"]>;
};
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<MSTeamsActivityHandler["run"]>;
};
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<MSTeamsActivityHandler["run"]>;
};
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));
});
});

View File

@@ -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<void>;
};
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;
}