mirror of https://github.com/openclaw/openclaw.git
439 lines
14 KiB
TypeScript
439 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../runtime-api.js";
|
|
import { deleteMessageMSTeams, editMessageMSTeams, sendMessageMSTeams } from "./send.js";
|
|
|
|
const mockState = vi.hoisted(() => ({
|
|
loadOutboundMediaFromUrl: vi.fn(),
|
|
resolveMSTeamsSendContext: vi.fn(),
|
|
requiresFileConsent: vi.fn(),
|
|
prepareFileConsentActivity: vi.fn(),
|
|
extractFilename: vi.fn(async () => "fallback.bin"),
|
|
sendMSTeamsMessages: vi.fn(),
|
|
uploadAndShareSharePoint: vi.fn(),
|
|
getDriveItemProperties: vi.fn(),
|
|
buildTeamsFileInfoCard: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../runtime-api.js", () => ({
|
|
loadOutboundMediaFromUrl: mockState.loadOutboundMediaFromUrl,
|
|
}));
|
|
|
|
vi.mock("./send-context.js", () => ({
|
|
resolveMSTeamsSendContext: mockState.resolveMSTeamsSendContext,
|
|
}));
|
|
|
|
vi.mock("./file-consent-helpers.js", () => ({
|
|
requiresFileConsent: mockState.requiresFileConsent,
|
|
prepareFileConsentActivity: mockState.prepareFileConsentActivity,
|
|
}));
|
|
|
|
vi.mock("./media-helpers.js", () => ({
|
|
extractFilename: mockState.extractFilename,
|
|
extractMessageId: () => "message-1",
|
|
}));
|
|
|
|
vi.mock("./messenger.js", () => ({
|
|
sendMSTeamsMessages: mockState.sendMSTeamsMessages,
|
|
buildConversationReference: () => ({}),
|
|
}));
|
|
|
|
vi.mock("./runtime.js", () => ({
|
|
getMSTeamsRuntime: () => ({
|
|
channel: {
|
|
text: {
|
|
resolveMarkdownTableMode: () => "off",
|
|
convertMarkdownTables: (text: string) => text,
|
|
},
|
|
},
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./graph-upload.js", () => ({
|
|
uploadAndShareSharePoint: mockState.uploadAndShareSharePoint,
|
|
getDriveItemProperties: mockState.getDriveItemProperties,
|
|
uploadAndShareOneDrive: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./graph-chat.js", () => ({
|
|
buildTeamsFileInfoCard: mockState.buildTeamsFileInfoCard,
|
|
}));
|
|
|
|
function mockContinueConversationFailure(error: string) {
|
|
const mockContinueConversation = vi.fn().mockRejectedValue(new Error(error));
|
|
mockState.resolveMSTeamsSendContext.mockResolvedValue({
|
|
adapter: { continueConversation: mockContinueConversation },
|
|
appId: "app-id",
|
|
conversationId: "19:conversation@thread.tacv2",
|
|
ref: {
|
|
user: { id: "user-1" },
|
|
agent: { id: "agent-1" },
|
|
conversation: { id: "19:conversation@thread.tacv2" },
|
|
channelId: "msteams",
|
|
},
|
|
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
conversationType: "personal",
|
|
tokenProvider: {},
|
|
});
|
|
return mockContinueConversation;
|
|
}
|
|
|
|
function createSharePointSendContext(params: {
|
|
conversationId: string;
|
|
graphChatId: string | null;
|
|
siteId: string;
|
|
}) {
|
|
return {
|
|
adapter: {
|
|
continueConversation: vi.fn(
|
|
async (
|
|
_id: string,
|
|
_ref: unknown,
|
|
fn: (ctx: { sendActivity: () => { id: "msg-1" } }) => Promise<void>,
|
|
) => fn({ sendActivity: () => ({ id: "msg-1" }) }),
|
|
),
|
|
},
|
|
appId: "app-id",
|
|
conversationId: params.conversationId,
|
|
graphChatId: params.graphChatId,
|
|
ref: {},
|
|
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
conversationType: "groupChat" as const,
|
|
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
|
mediaMaxBytes: 8 * 1024 * 1024,
|
|
sharePointSiteId: params.siteId,
|
|
};
|
|
}
|
|
|
|
function mockSharePointPdfUpload(params: {
|
|
bufferSize: number;
|
|
fileName: string;
|
|
itemId: string;
|
|
uniqueId: string;
|
|
}) {
|
|
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
|
buffer: Buffer.alloc(params.bufferSize, "pdf"),
|
|
contentType: "application/pdf",
|
|
fileName: params.fileName,
|
|
kind: "file",
|
|
});
|
|
mockState.requiresFileConsent.mockReturnValue(false);
|
|
mockState.uploadAndShareSharePoint.mockResolvedValue({
|
|
itemId: params.itemId,
|
|
webUrl: `https://sp.example.com/${params.fileName}`,
|
|
shareUrl: `https://sp.example.com/share/${params.fileName}`,
|
|
name: params.fileName,
|
|
});
|
|
mockState.getDriveItemProperties.mockResolvedValue({
|
|
eTag: `"${params.uniqueId},1"`,
|
|
webDavUrl: `https://sp.example.com/dav/${params.fileName}`,
|
|
name: params.fileName,
|
|
});
|
|
mockState.buildTeamsFileInfoCard.mockReturnValue({
|
|
contentType: "application/vnd.microsoft.teams.card.file.info",
|
|
contentUrl: `https://sp.example.com/dav/${params.fileName}`,
|
|
name: params.fileName,
|
|
content: { uniqueId: params.uniqueId, fileType: "pdf" },
|
|
});
|
|
}
|
|
|
|
describe("sendMessageMSTeams", () => {
|
|
beforeEach(() => {
|
|
mockState.loadOutboundMediaFromUrl.mockReset();
|
|
mockState.resolveMSTeamsSendContext.mockReset();
|
|
mockState.requiresFileConsent.mockReset();
|
|
mockState.prepareFileConsentActivity.mockReset();
|
|
mockState.extractFilename.mockReset();
|
|
mockState.sendMSTeamsMessages.mockReset();
|
|
mockState.uploadAndShareSharePoint.mockReset();
|
|
mockState.getDriveItemProperties.mockReset();
|
|
mockState.buildTeamsFileInfoCard.mockReset();
|
|
|
|
mockState.extractFilename.mockResolvedValue("fallback.bin");
|
|
mockState.requiresFileConsent.mockReturnValue(false);
|
|
mockState.resolveMSTeamsSendContext.mockResolvedValue({
|
|
adapter: {},
|
|
appId: "app-id",
|
|
conversationId: "19:conversation@thread.tacv2",
|
|
ref: {},
|
|
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
conversationType: "personal",
|
|
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
|
mediaMaxBytes: 8 * 1024,
|
|
sharePointSiteId: undefined,
|
|
});
|
|
mockState.sendMSTeamsMessages.mockResolvedValue(["message-1"]);
|
|
});
|
|
|
|
it("loads media through shared helper and forwards mediaLocalRoots", async () => {
|
|
const mediaBuffer = Buffer.from("tiny-image");
|
|
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
|
buffer: mediaBuffer,
|
|
contentType: "image/png",
|
|
fileName: "inline.png",
|
|
kind: "image",
|
|
});
|
|
|
|
await sendMessageMSTeams({
|
|
cfg: {} as OpenClawConfig,
|
|
to: "conversation:19:conversation@thread.tacv2",
|
|
text: "hello",
|
|
mediaUrl: "file:///tmp/agent-workspace/inline.png",
|
|
mediaLocalRoots: ["/tmp/agent-workspace"],
|
|
});
|
|
|
|
expect(mockState.loadOutboundMediaFromUrl).toHaveBeenCalledWith(
|
|
"file:///tmp/agent-workspace/inline.png",
|
|
{
|
|
maxBytes: 8 * 1024,
|
|
mediaLocalRoots: ["/tmp/agent-workspace"],
|
|
},
|
|
);
|
|
|
|
expect(mockState.sendMSTeamsMessages).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
messages: [
|
|
expect.objectContaining({
|
|
text: "hello",
|
|
mediaUrl: `data:image/png;base64,${mediaBuffer.toString("base64")}`,
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses graphChatId instead of conversationId when uploading to SharePoint", async () => {
|
|
// Simulates a group chat where Bot Framework conversationId is valid but we have
|
|
// a resolved Graph chat ID cached from a prior send.
|
|
const graphChatId = "19:graph-native-chat-id@thread.tacv2";
|
|
const botFrameworkConversationId = "19:bot-framework-id@thread.tacv2";
|
|
|
|
mockState.resolveMSTeamsSendContext.mockResolvedValue(
|
|
createSharePointSendContext({
|
|
conversationId: botFrameworkConversationId,
|
|
graphChatId,
|
|
siteId: "site-123",
|
|
}),
|
|
);
|
|
mockSharePointPdfUpload({
|
|
bufferSize: 100,
|
|
fileName: "doc.pdf",
|
|
itemId: "item-1",
|
|
uniqueId: "{GUID-123}",
|
|
});
|
|
|
|
await sendMessageMSTeams({
|
|
cfg: {} as OpenClawConfig,
|
|
to: "conversation:19:bot-framework-id@thread.tacv2",
|
|
text: "here is a file",
|
|
mediaUrl: "https://example.com/doc.pdf",
|
|
});
|
|
|
|
// The Graph-native chatId must be passed to SharePoint upload, not the Bot Framework ID
|
|
expect(mockState.uploadAndShareSharePoint).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
chatId: graphChatId,
|
|
siteId: "site-123",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("falls back to conversationId when graphChatId is not available", async () => {
|
|
const botFrameworkConversationId = "19:fallback-id@thread.tacv2";
|
|
|
|
mockState.resolveMSTeamsSendContext.mockResolvedValue(
|
|
createSharePointSendContext({
|
|
conversationId: botFrameworkConversationId,
|
|
graphChatId: null,
|
|
siteId: "site-456",
|
|
}),
|
|
);
|
|
mockSharePointPdfUpload({
|
|
bufferSize: 50,
|
|
fileName: "report.pdf",
|
|
itemId: "item-2",
|
|
uniqueId: "{GUID-456}",
|
|
});
|
|
|
|
await sendMessageMSTeams({
|
|
cfg: {} as OpenClawConfig,
|
|
to: "conversation:19:fallback-id@thread.tacv2",
|
|
text: "report",
|
|
mediaUrl: "https://example.com/report.pdf",
|
|
});
|
|
|
|
// Falls back to conversationId when graphChatId is null
|
|
expect(mockState.uploadAndShareSharePoint).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
chatId: botFrameworkConversationId,
|
|
siteId: "site-456",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("editMessageMSTeams", () => {
|
|
beforeEach(() => {
|
|
mockState.resolveMSTeamsSendContext.mockReset();
|
|
});
|
|
|
|
it("calls continueConversation and updateActivity with correct params", async () => {
|
|
const mockUpdateActivity = vi.fn();
|
|
const mockContinueConversation = vi.fn(
|
|
async (_appId: string, _ref: unknown, logic: (ctx: unknown) => Promise<void>) => {
|
|
await logic({
|
|
sendActivity: vi.fn(),
|
|
updateActivity: mockUpdateActivity,
|
|
deleteActivity: vi.fn(),
|
|
});
|
|
},
|
|
);
|
|
mockState.resolveMSTeamsSendContext.mockResolvedValue({
|
|
adapter: { continueConversation: mockContinueConversation },
|
|
appId: "app-id",
|
|
conversationId: "19:conversation@thread.tacv2",
|
|
ref: {
|
|
user: { id: "user-1" },
|
|
agent: { id: "agent-1" },
|
|
conversation: { id: "19:conversation@thread.tacv2", conversationType: "personal" },
|
|
channelId: "msteams",
|
|
},
|
|
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
conversationType: "personal",
|
|
tokenProvider: {},
|
|
});
|
|
|
|
const result = await editMessageMSTeams({
|
|
cfg: {} as OpenClawConfig,
|
|
to: "conversation:19:conversation@thread.tacv2",
|
|
activityId: "activity-123",
|
|
text: "Updated message text",
|
|
});
|
|
|
|
expect(result.conversationId).toBe("19:conversation@thread.tacv2");
|
|
expect(mockContinueConversation).toHaveBeenCalledTimes(1);
|
|
expect(mockContinueConversation).toHaveBeenCalledWith(
|
|
"app-id",
|
|
expect.objectContaining({ activityId: undefined }),
|
|
expect.any(Function),
|
|
);
|
|
expect(mockUpdateActivity).toHaveBeenCalledWith({
|
|
type: "message",
|
|
id: "activity-123",
|
|
text: "Updated message text",
|
|
});
|
|
});
|
|
|
|
it("throws a descriptive error when continueConversation fails", async () => {
|
|
mockContinueConversationFailure("Service unavailable");
|
|
|
|
await expect(
|
|
editMessageMSTeams({
|
|
cfg: {} as OpenClawConfig,
|
|
to: "conversation:19:conversation@thread.tacv2",
|
|
activityId: "activity-123",
|
|
text: "Updated text",
|
|
}),
|
|
).rejects.toThrow("msteams edit failed");
|
|
});
|
|
});
|
|
|
|
describe("deleteMessageMSTeams", () => {
|
|
beforeEach(() => {
|
|
mockState.resolveMSTeamsSendContext.mockReset();
|
|
});
|
|
|
|
it("calls continueConversation and deleteActivity with correct activityId", async () => {
|
|
const mockDeleteActivity = vi.fn();
|
|
const mockContinueConversation = vi.fn(
|
|
async (_appId: string, _ref: unknown, logic: (ctx: unknown) => Promise<void>) => {
|
|
await logic({
|
|
sendActivity: vi.fn(),
|
|
updateActivity: vi.fn(),
|
|
deleteActivity: mockDeleteActivity,
|
|
});
|
|
},
|
|
);
|
|
mockState.resolveMSTeamsSendContext.mockResolvedValue({
|
|
adapter: { continueConversation: mockContinueConversation },
|
|
appId: "app-id",
|
|
conversationId: "19:conversation@thread.tacv2",
|
|
ref: {
|
|
user: { id: "user-1" },
|
|
agent: { id: "agent-1" },
|
|
conversation: { id: "19:conversation@thread.tacv2", conversationType: "groupChat" },
|
|
channelId: "msteams",
|
|
},
|
|
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
conversationType: "groupChat",
|
|
tokenProvider: {},
|
|
});
|
|
|
|
const result = await deleteMessageMSTeams({
|
|
cfg: {} as OpenClawConfig,
|
|
to: "conversation:19:conversation@thread.tacv2",
|
|
activityId: "activity-456",
|
|
});
|
|
|
|
expect(result.conversationId).toBe("19:conversation@thread.tacv2");
|
|
expect(mockContinueConversation).toHaveBeenCalledTimes(1);
|
|
expect(mockContinueConversation).toHaveBeenCalledWith(
|
|
"app-id",
|
|
expect.objectContaining({ activityId: undefined }),
|
|
expect.any(Function),
|
|
);
|
|
expect(mockDeleteActivity).toHaveBeenCalledWith("activity-456");
|
|
});
|
|
|
|
it("throws a descriptive error when continueConversation fails", async () => {
|
|
mockContinueConversationFailure("Not found");
|
|
|
|
await expect(
|
|
deleteMessageMSTeams({
|
|
cfg: {} as OpenClawConfig,
|
|
to: "conversation:19:conversation@thread.tacv2",
|
|
activityId: "activity-456",
|
|
}),
|
|
).rejects.toThrow("msteams delete failed");
|
|
});
|
|
|
|
it("passes the appId and proactive ref to continueConversation", async () => {
|
|
const mockContinueConversation = vi.fn(
|
|
async (_appId: string, _ref: unknown, logic: (ctx: unknown) => Promise<void>) => {
|
|
await logic({
|
|
sendActivity: vi.fn(),
|
|
updateActivity: vi.fn(),
|
|
deleteActivity: vi.fn(),
|
|
});
|
|
},
|
|
);
|
|
mockState.resolveMSTeamsSendContext.mockResolvedValue({
|
|
adapter: { continueConversation: mockContinueConversation },
|
|
appId: "my-app-id",
|
|
conversationId: "19:conv@thread.tacv2",
|
|
ref: {
|
|
activityId: "original-activity",
|
|
user: { id: "user-1" },
|
|
agent: { id: "agent-1" },
|
|
conversation: { id: "19:conv@thread.tacv2" },
|
|
channelId: "msteams",
|
|
},
|
|
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
conversationType: "personal",
|
|
tokenProvider: {},
|
|
});
|
|
|
|
await deleteMessageMSTeams({
|
|
cfg: {} as OpenClawConfig,
|
|
to: "conversation:19:conv@thread.tacv2",
|
|
activityId: "activity-789",
|
|
});
|
|
|
|
// appId should be forwarded correctly
|
|
expect(mockContinueConversation.mock.calls[0]?.[0]).toBe("my-app-id");
|
|
// activityId on the proactive ref should be cleared (undefined) — proactive pattern
|
|
expect(mockContinueConversation.mock.calls[0]?.[1]).toMatchObject({
|
|
activityId: undefined,
|
|
});
|
|
});
|
|
});
|