mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
ea78c2488b
* feat(msteams): deliver native Adaptive Card approvals Exec and gateway plugin approvals now render as Adaptive Cards in Microsoft Teams with token-bound approve/deny actions, mirroring the Google Chat card pattern. Card submits are intercepted before message-text serialization, authorized against channels.msteams.allowFrom/defaultTo AAD object IDs via the existing approval auth, claimed once, resolved over the gateway, and the card is updated in place to its terminal state. Native delivery gates on the top-level approvals.exec/approvals.plugin forwarding config; the /approve text fallback remains. * chore(msteams): shrink assertion-safety baseline after send.ts cast removal * chore(msteams): record approval-native adapter seam in chained-assertion ledger * fix(msteams): surface a text approval fallback when card delivery fails When the native route suppressed the local text prompt, a failed Adaptive Card send only logged, leaving the pending approval invisible. On delivery error, send a plain-text /approve prompt to the planned target so the operator always has a visible approval path. Addresses the ClawSweeper P1 on #129997 channel-locally; #130040 tracks the shared-boundary fix.
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
// Msteams tests cover monitor handler.adaptive card plugin behavior.
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
|
|
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
|
import { type MSTeamsActivityHandler, registerMSTeamsHandlers } from "./monitor-handler.js";
|
|
import {
|
|
createActivityHandler,
|
|
getMSTeamsTestRuntimeState,
|
|
installMSTeamsTestRuntime,
|
|
} from "./monitor-handler.test-helpers.js";
|
|
import type { MSTeamsMessageHandlerDeps } from "./monitor-handler.types.js";
|
|
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
|
|
|
const runtimeApiMockState = getMSTeamsTestRuntimeState();
|
|
|
|
vi.mock("./reply-dispatcher.js", () => ({
|
|
createMSTeamsReplyDispatcher: () => ({
|
|
dispatcherOptions: {},
|
|
delivery: { deliver: vi.fn(async () => undefined) },
|
|
replyOptions: {},
|
|
}),
|
|
}));
|
|
|
|
function createDeps(): MSTeamsMessageHandlerDeps {
|
|
installMSTeamsTestRuntime();
|
|
|
|
return {
|
|
cfg: {} as OpenClawConfig,
|
|
runtime: { error: vi.fn() } as unknown as RuntimeEnv,
|
|
appId: "test-app",
|
|
app: {} as MSTeamsMessageHandlerDeps["app"],
|
|
tokenProvider: {
|
|
getAccessToken: vi.fn(async () => "token"),
|
|
},
|
|
textLimit: 4000,
|
|
mediaMaxBytes: 1024 * 1024,
|
|
conversationStore: {
|
|
get: vi.fn(async () => null),
|
|
upsert: vi.fn(async () => undefined),
|
|
list: vi.fn(async () => []),
|
|
remove: vi.fn(async () => false),
|
|
findPreferredDmByUserId: vi.fn(async () => null),
|
|
} satisfies MSTeamsConversationStore,
|
|
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"],
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
async function runMessageActivity(params: {
|
|
value?: unknown;
|
|
text?: string;
|
|
deps?: MSTeamsMessageHandlerDeps;
|
|
}) {
|
|
const deps = params.deps ?? createDeps();
|
|
let messageHandler: Parameters<MSTeamsActivityHandler["onMessage"]>[0] | undefined;
|
|
const handler: MSTeamsActivityHandler = {
|
|
onMessage: (callback) => {
|
|
messageHandler = callback;
|
|
return handler;
|
|
},
|
|
onMembersAdded: () => handler,
|
|
onReactionsAdded: () => handler,
|
|
onReactionsRemoved: () => handler,
|
|
run: vi.fn(async () => undefined),
|
|
};
|
|
registerMSTeamsHandlers(handler, deps);
|
|
await messageHandler?.(
|
|
{
|
|
activity: {
|
|
id: "message-1",
|
|
type: "message",
|
|
text: params.text ?? "",
|
|
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",
|
|
conversationType: "personal",
|
|
},
|
|
channelData: {},
|
|
attachments: [],
|
|
value: params.value,
|
|
},
|
|
sendActivity: vi.fn(async () => ({ id: "activity-id" })),
|
|
sendActivities: async () => [],
|
|
} as unknown as MSTeamsTurnContext,
|
|
vi.fn(async () => undefined),
|
|
);
|
|
}
|
|
|
|
function lastDispatchedCtxPayload(): Record<string, unknown> {
|
|
const dispatched = runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher.mock.calls.at(
|
|
-1,
|
|
)?.[0] as { ctx?: Record<string, unknown> } | undefined;
|
|
if (!dispatched?.ctx) {
|
|
throw new Error("expected dispatched context payload");
|
|
}
|
|
return dispatched.ctx;
|
|
}
|
|
|
|
describe("msteams adaptive card action invoke", () => {
|
|
beforeEach(() => {
|
|
runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher.mockClear();
|
|
});
|
|
|
|
it("forwards adaptive card submitted data to the agent as message text", async () => {
|
|
const deps = createDeps();
|
|
const run = vi.fn(async () => undefined);
|
|
const handler = createActivityHandler(run);
|
|
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 runAdaptiveCardInvoke(registered, payload);
|
|
|
|
expect(run).not.toHaveBeenCalled();
|
|
expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
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));
|
|
});
|
|
|
|
it.each([
|
|
{ activity: "invoke", token: "unknown-token" },
|
|
{ activity: "invoke", token: "" },
|
|
{ activity: "message", token: "unknown-token" },
|
|
{ activity: "message", token: undefined },
|
|
])(
|
|
"does not dispatch a rejected approval submit from a $activity activity",
|
|
async ({ activity, token }) => {
|
|
const deps = createDeps();
|
|
const data = {
|
|
openclawAction: "approval",
|
|
...(token !== undefined ? { token } : {}),
|
|
};
|
|
|
|
if (activity === "invoke") {
|
|
const handler = createActivityHandler();
|
|
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
|
|
run: NonNullable<MSTeamsActivityHandler["run"]>;
|
|
};
|
|
await runAdaptiveCardInvoke(registered, {
|
|
action: { type: "Action.Submit", data },
|
|
});
|
|
} else {
|
|
await runMessageActivity({ value: data, deps });
|
|
}
|
|
|
|
expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
|
expect(deps.log.info).toHaveBeenCalledWith("msteams approval ignored", {
|
|
reason: token ? "unknown or expired card token" : "missing card token",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("routes message activities with submitted card values as message text", async () => {
|
|
const data = { value: "button-submit-value", label: "Submit action" };
|
|
|
|
await runMessageActivity({ value: data });
|
|
|
|
const ctxPayload = lastDispatchedCtxPayload();
|
|
expect(ctxPayload.BodyForAgent).toBe(JSON.stringify(data));
|
|
expect(ctxPayload.CommandBody).toBe(JSON.stringify(data));
|
|
expect(ctxPayload.SessionKey).toBe("msteams:direct:user-aad");
|
|
expect(ctxPayload.SenderId).toBe("user-aad");
|
|
});
|
|
|
|
it("keeps activity text ahead of submitted card values on normal messages", async () => {
|
|
await runMessageActivity({
|
|
text: "typed text",
|
|
value: { value: "card-value", label: "Card value" },
|
|
});
|
|
|
|
const ctxPayload = lastDispatchedCtxPayload();
|
|
expect(ctxPayload.BodyForAgent).toBe("typed text");
|
|
expect(ctxPayload.CommandBody).toBe("typed text");
|
|
});
|
|
});
|