diff --git a/extensions/msteams/src/channel.runtime.ts b/extensions/msteams/src/channel.runtime.ts index 4e90f394bb2..4de6a10a046 100644 --- a/extensions/msteams/src/channel.runtime.ts +++ b/extensions/msteams/src/channel.runtime.ts @@ -2,6 +2,7 @@ import { listMSTeamsDirectoryGroupsLive as listMSTeamsDirectoryGroupsLiveImpl, listMSTeamsDirectoryPeersLive as listMSTeamsDirectoryPeersLiveImpl, } from "./directory-live.js"; +import { getMemberInfoMSTeams as getMemberInfoMSTeamsImpl } from "./graph-members.js"; import { getMessageMSTeams as getMessageMSTeamsImpl, listPinsMSTeams as listPinsMSTeamsImpl, @@ -23,6 +24,7 @@ import { export const msTeamsChannelRuntime = { deleteMessageMSTeams: deleteMessageMSTeamsImpl, editMessageMSTeams: editMessageMSTeamsImpl, + getMemberInfoMSTeams: getMemberInfoMSTeamsImpl, getMessageMSTeams: getMessageMSTeamsImpl, listPinsMSTeams: listPinsMSTeamsImpl, listReactionsMSTeams: listReactionsMSTeamsImpl, diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 87c480aea97..c4c4794d424 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -329,6 +329,7 @@ function describeMSTeamsMessageTool({ "react", "reactions", "search", + "member-info", ] satisfies ChannelMessageActionName[]) : [], capabilities: enabled ? ["cards"] : [], @@ -842,6 +843,16 @@ export const msteamsPlugin: ChannelPlugin ({ + resolveGraphToken: vi.fn(), + fetchGraphJson: vi.fn(), +})); + +vi.mock("./graph.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveGraphToken: mockState.resolveGraphToken, + fetchGraphJson: mockState.fetchGraphJson, + }; +}); + +const TOKEN = "test-graph-token"; + +describe("getMemberInfoMSTeams", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockState.resolveGraphToken.mockResolvedValue(TOKEN); + }); + + it("fetches user profile and maps all fields", async () => { + mockState.fetchGraphJson.mockResolvedValue({ + id: "user-123", + displayName: "Alice Smith", + mail: "alice@contoso.com", + jobTitle: "Engineer", + userPrincipalName: "alice@contoso.com", + officeLocation: "Building 1", + }); + + const result = await getMemberInfoMSTeams({ + cfg: {} as OpenClawConfig, + userId: "user-123", + }); + + expect(result).toEqual({ + user: { + id: "user-123", + displayName: "Alice Smith", + mail: "alice@contoso.com", + jobTitle: "Engineer", + userPrincipalName: "alice@contoso.com", + officeLocation: "Building 1", + }, + }); + expect(mockState.fetchGraphJson).toHaveBeenCalledWith({ + token: TOKEN, + path: `/users/${encodeURIComponent("user-123")}?$select=id,displayName,mail,jobTitle,userPrincipalName,officeLocation`, + }); + }); + + it("handles sparse data with some fields undefined", async () => { + mockState.fetchGraphJson.mockResolvedValue({ + id: "user-456", + displayName: "Bob", + }); + + const result = await getMemberInfoMSTeams({ + cfg: {} as OpenClawConfig, + userId: "user-456", + }); + + expect(result).toEqual({ + user: { + id: "user-456", + displayName: "Bob", + mail: undefined, + jobTitle: undefined, + userPrincipalName: undefined, + officeLocation: undefined, + }, + }); + }); + + it("propagates Graph API errors", async () => { + mockState.fetchGraphJson.mockRejectedValue(new Error("Graph API 404: user not found")); + + await expect( + getMemberInfoMSTeams({ + cfg: {} as OpenClawConfig, + userId: "nonexistent-user", + }), + ).rejects.toThrow("Graph API 404: user not found"); + }); +}); diff --git a/extensions/msteams/src/graph-members.ts b/extensions/msteams/src/graph-members.ts new file mode 100644 index 00000000000..6c2b24b3eab --- /dev/null +++ b/extensions/msteams/src/graph-members.ts @@ -0,0 +1,48 @@ +import type { OpenClawConfig } from "../runtime-api.js"; +import { fetchGraphJson, resolveGraphToken } from "./graph.js"; + +type GraphUserProfile = { + id?: string; + displayName?: string; + mail?: string; + jobTitle?: string; + userPrincipalName?: string; + officeLocation?: string; +}; + +export type GetMemberInfoMSTeamsParams = { + cfg: OpenClawConfig; + userId: string; +}; + +export type GetMemberInfoMSTeamsResult = { + user: { + id: string | undefined; + displayName: string | undefined; + mail: string | undefined; + jobTitle: string | undefined; + userPrincipalName: string | undefined; + officeLocation: string | undefined; + }; +}; + +/** + * Fetch a user profile from Microsoft Graph by user ID. + */ +export async function getMemberInfoMSTeams( + params: GetMemberInfoMSTeamsParams, +): Promise { + const token = await resolveGraphToken(params.cfg); + const path = `/users/${encodeURIComponent(params.userId)}?$select=id,displayName,mail,jobTitle,userPrincipalName,officeLocation`; + const user = await fetchGraphJson({ token, path }); + return { + user: { + id: user.id, + displayName: user.displayName, + mail: user.mail, + jobTitle: user.jobTitle, + userPrincipalName: user.userPrincipalName, + officeLocation: user.officeLocation, + }, + }; +}