fix(msteams): handle Adaptive Card Action.Submit invoke activities (#60431)

* fix(msteams): handle Adaptive Card Action.Submit invoke activities (#55384)

* ci: retrigger checks

---------

Co-authored-by: Brad Groux <bradgroux@users.noreply.github.com>
This commit is contained in:
Brad Groux
2026-04-04 02:38:51 -05:00
committed by GitHub
parent 1b2fb6b98b
commit 06c6ff6670
2 changed files with 225 additions and 0 deletions

View File

@@ -0,0 +1,194 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import {
type MSTeamsActivityHandler,
type MSTeamsMessageHandlerDeps,
registerMSTeamsHandlers,
} from "./monitor-handler.js";
import { setMSTeamsRuntime } from "./runtime.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
const runtimeApiMockState = vi.hoisted(() => ({
dispatchReplyFromConfigWithSettledDispatcher: vi.fn(async (params: { ctxPayload: unknown }) => ({
queuedFinal: false,
counts: {},
capturedCtxPayload: params.ctxPayload,
})),
}));
vi.mock("../runtime-api.js", async () => {
const actual = await vi.importActual<typeof import("../runtime-api.js")>("../runtime-api.js");
return {
...actual,
dispatchReplyFromConfigWithSettledDispatcher:
runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher,
};
});
vi.mock("./reply-dispatcher.js", () => ({
createMSTeamsReplyDispatcher: () => ({
dispatcher: {},
replyOptions: {},
markDispatchIdle: vi.fn(),
}),
}));
function createDeps(): MSTeamsMessageHandlerDeps {
setMSTeamsRuntime({
logging: { shouldLogVerbose: () => false },
system: { enqueueSystemEvent: vi.fn() },
channel: {
debounce: {
resolveInboundDebounceMs: () => 0,
createInboundDebouncer: <T>(params: {
onFlush: (entries: T[]) => Promise<void>;
}): { enqueue: (entry: T) => Promise<void> } => ({
enqueue: async (entry: T) => {
await params.onFlush([entry]);
},
}),
},
pairing: {
readAllowFromStore: vi.fn(async () => []),
upsertPairingRequest: vi.fn(async () => null),
},
text: {
hasControlCommand: () => false,
},
routing: {
resolveAgentRoute: ({ peer }: { peer: { kind: string; id: string } }) => ({
sessionKey: `msteams:${peer.kind}:${peer.id}`,
agentId: "default",
accountId: "default",
}),
},
reply: {
formatAgentEnvelope: ({ body }: { body: string }) => body,
finalizeInboundContext: <T extends Record<string, unknown>>(ctx: T) => ctx,
},
session: {
recordInboundSession: vi.fn(async () => undefined),
},
},
} as unknown as PluginRuntime);
return {
cfg: {} as OpenClawConfig,
runtime: { error: vi.fn() } as unknown as RuntimeEnv,
appId: "test-app",
adapter: {} as MSTeamsMessageHandlerDeps["adapter"],
tokenProvider: {
getAccessToken: vi.fn(async () => "token"),
},
textLimit: 4000,
mediaMaxBytes: 1024 * 1024,
conversationStore: {
upsert: vi.fn(async () => undefined),
} as unknown as MSTeamsMessageHandlerDeps["conversationStore"],
pollStore: {
recordVote: vi.fn(async () => null),
} as unknown as MSTeamsMessageHandlerDeps["pollStore"],
log: {
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
} as unknown as MSTeamsMessageHandlerDeps["log"],
};
}
function createActivityHandler() {
const messageHandlers: Array<(context: unknown, next: () => Promise<void>) => Promise<void>> = [];
const run = vi.fn(async (context: unknown) => {
const activityType = (context as MSTeamsTurnContext).activity?.type;
if (activityType !== "message") {
return;
}
for (const handler of messageHandlers) {
await handler(context, async () => {});
}
});
let handler: MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
handler = {
onMessage: (nextHandler) => {
messageHandlers.push(nextHandler);
return handler;
},
onMembersAdded: () => handler,
onReactionsAdded: () => handler,
onReactionsRemoved: () => handler,
run,
};
return { handler, run };
}
describe("msteams adaptive card action invoke", () => {
beforeEach(() => {
runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mockClear();
});
it("forwards adaptive card invoke values to the agent as message text", async () => {
const deps = createDeps();
const { handler, run } = createActivityHandler();
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
const payload = {
action: {
type: "Action.Submit",
data: {
intent: "deploy",
environment: "prod",
},
},
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);
expect(run).not.toHaveBeenCalled();
expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).toHaveBeenCalledTimes(
1,
);
expect(
runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock.calls[0]?.[0],
).toMatchObject({
ctxPayload: {
RawBody: JSON.stringify(payload),
BodyForAgent: JSON.stringify(payload),
CommandBody: JSON.stringify(payload),
SessionKey: "msteams:direct:user-aad",
SenderId: "user-aad",
},
});
});
});

View File

@@ -48,6 +48,21 @@ export type MSTeamsMessageHandlerDeps = {
log: MSTeamsMonitorLogger;
};
function serializeAdaptiveCardActionValue(value: unknown): string | null {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
if (value === undefined) {
return null;
}
try {
return JSON.stringify(value);
} catch {
return null;
}
}
async function isFeedbackInvokeAuthorized(
context: MSTeamsTurnContext,
deps: MSTeamsMessageHandlerDeps,
@@ -389,6 +404,22 @@ export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
}
}
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "adaptiveCard/action") {
const text = serializeAdaptiveCardActionValue(ctx.activity?.value);
if (text) {
await handleTeamsMessage({
...ctx,
activity: {
...ctx.activity,
type: "message",
text,
},
});
return;
}
deps.log.debug?.("skipping adaptive card action invoke without value payload");
}
return originalRun.call(handler, context);
};
}