From dcb04fbb162e5efeddd5361ef86521f27e27a65b Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sun, 19 Apr 2026 22:09:53 -0700 Subject: [PATCH 1/2] feat(adapter-teams): add getUser() via Microsoft Graph API - Cache aadObjectId from activity.from during webhook handling - Implement getUser() using Graph GET /users/{user-id} endpoint - Requires User.Read.All application permission - Returns null gracefully when user hasn't interacted or Graph call fails --- .changeset/teams-get-user.md | 5 + packages/adapter-teams/src/index.test.ts | 206 +++++++++++++++++++++++ packages/adapter-teams/src/index.ts | 46 +++++ 3 files changed, 257 insertions(+) create mode 100644 .changeset/teams-get-user.md 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/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 From 0fa25f503a9031a2877182bd254452089f027dbd Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 21 Apr 2026 21:42:01 -0700 Subject: [PATCH 2/2] docs: add getUser() section to Teams adapter README --- packages/adapter-teams/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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: