Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/teams-get-user.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/teams": minor
---

Add `getUser()` support for Teams adapter using Microsoft Graph API (requires `User.Read.All` permission)
16 changes: 16 additions & 0 deletions packages/adapter-teams/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down
206 changes: 206 additions & 0 deletions packages/adapter-teams/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>;
graph: { call: ReturnType<typeof vi.fn> };
};
}
).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<typeof adapter.initialize>[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<typeof vi.fn> };
}
).app;
mockApp.initialize = vi.fn(async () => undefined);

await adapter.initialize(
mockChat as unknown as Parameters<typeof adapter.initialize>[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<typeof vi.fn>;
graph: { call: ReturnType<typeof vi.fn> };
};
}
).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<typeof adapter.initialize>[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<typeof vi.fn>;
graph: { call: ReturnType<typeof vi.fn> };
};
}
).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<typeof adapter.initialize>[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();
});
});
});
46 changes: 46 additions & 0 deletions packages/adapter-teams/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -39,6 +40,7 @@ import type {
StreamChunk,
StreamOptions,
ThreadInfo,
UserInfo,
WebhookOptions,
} from "chat";
import {
Expand Down Expand Up @@ -189,6 +191,14 @@ export class TeamsAdapter implements Adapter<TeamsThreadId, unknown> {
.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;

Expand Down Expand Up @@ -841,6 +851,42 @@ export class TeamsAdapter implements Adapter<TeamsThreadId, unknown> {
return this.bridgeAdapter.dispatch(request, options);
}

async getUser(userId: string): Promise<UserInfo | null> {
if (!this.chat) {
return null;
}

try {
const aadObjectId = await this.chat
.getState()
.get<string>(`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
Expand Down