diff --git a/.changeset/teams-get-user.md b/.changeset/teams-get-user.md new file mode 100644 index 00000000..4cdbe81b --- /dev/null +++ b/.changeset/teams-get-user.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/teams": minor +--- + +Add `getUser()` support for Teams adapter using Microsoft Graph API (requires `User.Read.All` permission) diff --git a/packages/adapter-teams/README.md b/packages/adapter-teams/README.md index 25e45d68..b76adc6c 100644 --- a/packages/adapter-teams/README.md +++ b/packages/adapter-teams/README.md @@ -221,6 +221,7 @@ TEAMS_APP_TENANT_ID=... # Required for SingleTenant apps | Typing indicator | Yes | | DMs | Yes | | Ephemeral messages | No (DM fallback) | +| User lookup (`getUser`) | Yes (requires `User.Read.All`) | ### Message history @@ -234,6 +235,21 @@ TEAMS_APP_TENANT_ID=... # Required for SingleTenant apps | Fetch channel info | Yes (requires Graph permissions) | | Post channel message | Yes | +## User lookup (`getUser`) + +The adapter supports looking up user profiles via the Microsoft Graph API. To enable it: + +1. Grant the `User.Read.All` **application permission** in your Azure AD app registration +2. Grant admin consent for the permission + +```typescript +const user = await bot.getUser(message.author); +console.log(user?.email); // "alice@contoso.com" +console.log(user?.fullName); // "Alice Smith" +``` + +The adapter caches each user's Azure AD object ID from incoming activities, so `getUser` only works for users who have previously interacted with the bot. Returns `null` if the user hasn't been seen or the Graph call fails. + ## Message history (`fetchMessages`) Fetching message history requires the Microsoft Graph API with client credentials flow. To enable it: diff --git a/packages/adapter-teams/src/index.test.ts b/packages/adapter-teams/src/index.test.ts index 120f3fae..2da31daf 100644 --- a/packages/adapter-teams/src/index.test.ts +++ b/packages/adapter-teams/src/index.test.ts @@ -1027,4 +1027,210 @@ describe("TeamsAdapter", () => { await expect(adapter.openDM("user-123")).rejects.toThrow(ValidationError); }); }); + + // ========================================================================== + // getUser Tests + // ========================================================================== + + describe("getUser", () => { + it("should return user info when aadObjectId is cached and Graph call succeeds", async () => { + const adapter = new TeamsAdapter({ + appId: "test", + appPassword: "test", + logger, + }); + + const mockState = { + get: vi.fn(async (key: string) => { + if (key === "teams:aadObjectId:29:user-123") { + return "aad-object-id-456"; + } + return null; + }), + set: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + }; + const mockChat = { + getState: () => mockState, + processMessage: vi.fn(), + processAction: vi.fn(), + processReaction: vi.fn(), + }; + + const mockApp = ( + adapter as unknown as { + app: { + initialize: ReturnType; + graph: { call: ReturnType }; + }; + } + ).app; + mockApp.initialize = vi.fn(async () => undefined); + mockApp.graph = { + call: vi.fn(async () => ({ + displayName: "Alice Smith", + mail: "alice@contoso.com", + userPrincipalName: "alice@contoso.com", + id: "aad-object-id-456", + })), + }; + + await adapter.initialize( + mockChat as unknown as Parameters[0] + ); + + const user = await adapter.getUser("29:user-123"); + expect(user).not.toBeNull(); + expect(user?.fullName).toBe("Alice Smith"); + expect(user?.email).toBe("alice@contoso.com"); + expect(user?.userName).toBe("alice@contoso.com"); + expect(user?.userId).toBe("29:user-123"); + expect(user?.isBot).toBe(false); + }); + + it("should return null when aadObjectId is not cached", async () => { + const adapter = new TeamsAdapter({ + appId: "test", + appPassword: "test", + logger, + }); + + const mockState = { + get: vi.fn(async () => null), + set: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + }; + const mockChat = { + getState: () => mockState, + processMessage: vi.fn(), + processAction: vi.fn(), + processReaction: vi.fn(), + }; + + const mockApp = ( + adapter as unknown as { + app: { initialize: ReturnType }; + } + ).app; + mockApp.initialize = vi.fn(async () => undefined); + + await adapter.initialize( + mockChat as unknown as Parameters[0] + ); + + const user = await adapter.getUser("29:unknown-user"); + expect(user).toBeNull(); + }); + + it("should return null when Graph call fails", async () => { + const adapter = new TeamsAdapter({ + appId: "test", + appPassword: "test", + logger, + }); + + const mockState = { + get: vi.fn(async (key: string) => { + if (key === "teams:aadObjectId:29:user-123") { + return "aad-object-id-456"; + } + return null; + }), + set: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + }; + const mockChat = { + getState: () => mockState, + processMessage: vi.fn(), + processAction: vi.fn(), + processReaction: vi.fn(), + }; + + const mockApp = ( + adapter as unknown as { + app: { + initialize: ReturnType; + graph: { call: ReturnType }; + }; + } + ).app; + mockApp.initialize = vi.fn(async () => undefined); + mockApp.graph = { + call: vi.fn(async () => { + throw new Error("Forbidden"); + }), + }; + + await adapter.initialize( + mockChat as unknown as Parameters[0] + ); + + const user = await adapter.getUser("29:user-123"); + expect(user).toBeNull(); + }); + + it("should handle missing mail gracefully", async () => { + const adapter = new TeamsAdapter({ + appId: "test", + appPassword: "test", + logger, + }); + + const mockState = { + get: vi.fn(async (key: string) => { + if (key === "teams:aadObjectId:29:user-123") { + return "aad-object-id-456"; + } + return null; + }), + set: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + }; + const mockChat = { + getState: () => mockState, + processMessage: vi.fn(), + processAction: vi.fn(), + processReaction: vi.fn(), + }; + + const mockApp = ( + adapter as unknown as { + app: { + initialize: ReturnType; + graph: { call: ReturnType }; + }; + } + ).app; + mockApp.initialize = vi.fn(async () => undefined); + mockApp.graph = { + call: vi.fn(async () => ({ + displayName: "Bob Jones", + mail: null, + userPrincipalName: "bob@contoso.com", + id: "aad-object-id-456", + })), + }; + + await adapter.initialize( + mockChat as unknown as Parameters[0] + ); + + const user = await adapter.getUser("29:user-123"); + expect(user).not.toBeNull(); + expect(user?.fullName).toBe("Bob Jones"); + expect(user?.email).toBeUndefined(); + expect(user?.userName).toBe("bob@contoso.com"); + }); + + it("should return null when adapter is not initialized", async () => { + const adapter = new TeamsAdapter({ + appId: "test", + appPassword: "test", + logger, + }); + + const user = await adapter.getUser("29:user-123"); + expect(user).toBeNull(); + }); + }); }); diff --git a/packages/adapter-teams/src/index.ts b/packages/adapter-teams/src/index.ts index 96d51f1c..1a052d6b 100644 --- a/packages/adapter-teams/src/index.ts +++ b/packages/adapter-teams/src/index.ts @@ -18,6 +18,7 @@ import type { import { MessageActivity, TypingActivity } from "@microsoft/teams.api"; import type { IActivityContext } from "@microsoft/teams.apps"; import { App } from "@microsoft/teams.apps"; +import { users } from "@microsoft/teams.graph-endpoints"; import type { ActionEvent, Adapter, @@ -39,6 +40,7 @@ import type { StreamChunk, StreamOptions, ThreadInfo, + UserInfo, WebhookOptions, } from "chat"; import { @@ -189,6 +191,14 @@ export class TeamsAdapter implements Adapter { .catch(() => {}); } + // Cache aadObjectId for Graph API user lookups + if (activity.from.aadObjectId) { + this.chat + .getState() + .set(`teams:aadObjectId:${userId}`, activity.from.aadObjectId, ttl) + .catch(() => {}); + } + const channelData = activity.channelData; const tenantId = activity.conversation?.tenantId ?? channelData?.tenant?.id; @@ -841,6 +851,42 @@ export class TeamsAdapter implements Adapter { return this.bridgeAdapter.dispatch(request, options); } + async getUser(userId: string): Promise { + if (!this.chat) { + return null; + } + + try { + const aadObjectId = await this.chat + .getState() + .get(`teams:aadObjectId:${userId}`); + + if (!aadObjectId) { + this.logger.debug("No cached aadObjectId for user", { userId }); + return null; + } + + const graphUser = await this.app.graph.call(users.get, { + "user-id": aadObjectId, + }); + + return { + avatarUrl: undefined, + email: graphUser.mail ?? undefined, + fullName: graphUser.displayName ?? aadObjectId, + isBot: false, + userId, + userName: graphUser.userPrincipalName ?? graphUser.displayName ?? userId, + }; + } catch (error) { + this.logger.warn("Failed to fetch user info from Graph API", { + userId, + error, + }); + return null; + } + } + async postMessage( threadId: string, message: AdapterPostableMessage