diff --git a/.changeset/get-user.md b/.changeset/get-user.md
new file mode 100644
index 00000000..b7f9597b
--- /dev/null
+++ b/.changeset/get-user.md
@@ -0,0 +1,11 @@
+---
+"chat": minor
+"@chat-adapter/slack": minor
+"@chat-adapter/discord": minor
+"@chat-adapter/gchat": minor
+"@chat-adapter/github": minor
+"@chat-adapter/linear": minor
+"@chat-adapter/telegram": minor
+---
+
+Add `chat.getUser()` method and `UserInfo` type for cross-platform user lookups. Implement `getUser` on Slack, Discord, Google Chat, GitHub, Linear, and Telegram adapters.
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/apps/docs/content/docs/api/chat.mdx b/apps/docs/content/docs/api/chat.mdx
index c3757c56..dcc456a4 100644
--- a/apps/docs/content/docs/api/chat.mdx
+++ b/apps/docs/content/docs/api/chat.mdx
@@ -444,6 +444,80 @@ await dm.post("Hello via DM!");
const dm = await bot.openDM(message.author);
```
+### getUser
+
+Look up user information by user ID. Returns a `UserInfo` object with name, email, avatar, and bot status, or `null` if the user was not found. Supported on Slack, Microsoft Teams, Discord, Google Chat, GitHub, Linear, and Telegram. Other adapters will throw `NOT_SUPPORTED`.
+
+```typescript
+const user = await bot.getUser("U123456");
+console.log(user?.email); // "alice@company.com"
+console.log(user?.fullName); // "Alice Smith"
+```
+
+```typescript
+// Or with an Author object from a message handler
+const user = await bot.getUser(message.author);
+```
+
+
+
+
+ **Per-platform constraints:**
+ - **Slack** — requires both `users:read` and `users:read.email` scopes (the email scope must be granted at OAuth install time).
+ - **Discord** — bot tokens never see email (the `email` OAuth scope only applies in user-context auth).
+ - **Telegram** — bots can only look up users who have previously messaged them.
+ - **Microsoft Teams** — only works for users who previously interacted with the bot (cached from webhook activity). `avatarUrl` is not returned (Graph API requires a separate photo call).
+ - **Google Chat** — same caching constraint as Teams: only users seen in prior webhooks.
+ - **GitHub** — `email` is `null` unless the user made it public, or you authenticated with the `user:email` scope.
+ - **Linear** — full profile (incl. email + avatar) for any active workspace member.
+
+ Fields that aren't available return `undefined`. Numeric user IDs (Discord/Telegram/GitHub) can be ambiguous when multiple of those adapters are registered — call the platform's adapter directly (`adapter.getUser(userId)`) in that case.
+
+
+Adapters that don't support user lookups will throw a `ChatError` with code `NOT_SUPPORTED`. Handle both cases if your bot runs on multiple platforms:
+
+```typescript
+import { ChatError } from "chat";
+
+try {
+ const user = await bot.getUser(userId);
+ if (!user) {
+ // User not found on this platform
+ }
+} catch (error) {
+ if (error instanceof ChatError && error.code === "NOT_SUPPORTED") {
+ // This adapter doesn't support user lookups
+ }
+}
+```
+
### thread
Get a Thread handle by its thread ID. Useful for posting to threads outside of webhook contexts (e.g. cron jobs, external triggers).
diff --git a/apps/docs/content/docs/threads-messages-channels.mdx b/apps/docs/content/docs/threads-messages-channels.mdx
index 5c68f74e..b3b1ecec 100644
--- a/apps/docs/content/docs/threads-messages-channels.mdx
+++ b/apps/docs/content/docs/threads-messages-channels.mdx
@@ -171,6 +171,13 @@ interface Author {
}
```
+For richer user info (email, avatar), use [`chat.getUser()`](/docs/api/chat#getuser):
+
+```typescript title="lib/bot.ts"
+const user = await bot.getUser(message.author);
+console.log(user?.email); // "alice@company.com"
+```
+
### Sent messages
When you post a message, you get back a `SentMessage` with methods to edit, delete, and react:
diff --git a/examples/nextjs-chat/src/lib/bot.tsx b/examples/nextjs-chat/src/lib/bot.tsx
index 17bfda99..02ff10a5 100644
--- a/examples/nextjs-chat/src/lib/bot.tsx
+++ b/examples/nextjs-chat/src/lib/bot.tsx
@@ -124,6 +124,7 @@ bot.onNewMention(async (thread, message) => {
+
@@ -378,6 +379,36 @@ bot.onAction("info", async (event) => {
);
});
+bot.onAction("who-am-i", async (event) => {
+ if (!event.thread) {
+ return;
+ }
+ try {
+ const user = await bot.getUser(event.user);
+ if (!user) {
+ await event.thread.post(
+ `${emoji.warning} Could not find your user profile.`
+ );
+ return;
+ }
+ await event.thread.post(
+
+
+
+
+
+
+
+
+
+ );
+ } catch {
+ await event.thread.post(
+ `${emoji.warning} User lookup is not supported on this platform.`
+ );
+ }
+});
+
bot.onAction("goodbye", async (event) => {
if (!event.thread) {
return;
diff --git a/packages/adapter-discord/src/index.test.ts b/packages/adapter-discord/src/index.test.ts
index df85d694..01f7731f 100644
--- a/packages/adapter-discord/src/index.test.ts
+++ b/packages/adapter-discord/src/index.test.ts
@@ -4210,3 +4210,168 @@ describe("createDiscordThread 160004 recovery", () => {
spy.mockRestore();
});
});
+
+describe("getUser", () => {
+ it("should return user info from Discord API", async () => {
+ const adapter = createDiscordAdapter({
+ botToken: "test-token",
+ publicKey: testPublicKey,
+ applicationId: "test-app-id",
+ logger: mockLogger,
+ });
+
+ const spy = vi.spyOn(adapter as any, "discordFetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ id: "123456",
+ username: "alice",
+ global_name: "Alice Smith",
+ avatar: "abc123",
+ bot: false,
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ )
+ );
+
+ const user = await adapter.getUser("123456");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Alice Smith");
+ expect(user?.userName).toBe("alice");
+ expect(user?.avatarUrl).toBe(
+ "https://cdn.discordapp.com/avatars/123456/abc123.png"
+ );
+ expect(user?.isBot).toBe(false);
+ expect(user?.email).toBeUndefined();
+
+ spy.mockRestore();
+ });
+
+ it("should return null on error", async () => {
+ const adapter = createDiscordAdapter({
+ botToken: "test-token",
+ publicKey: testPublicKey,
+ applicationId: "test-app-id",
+ logger: mockLogger,
+ });
+
+ const spy = vi
+ .spyOn(adapter as any, "discordFetch")
+ .mockRejectedValue(new Error("Not found"));
+
+ const user = await adapter.getUser("999999");
+ expect(user).toBeNull();
+
+ spy.mockRestore();
+ });
+
+ it("should return undefined avatarUrl when avatar is null", async () => {
+ const adapter = createDiscordAdapter({
+ botToken: "test-token",
+ publicKey: testPublicKey,
+ applicationId: "test-app-id",
+ logger: mockLogger,
+ });
+
+ const spy = vi.spyOn(adapter as any, "discordFetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ id: "111222",
+ username: "noavatar",
+ global_name: "No Avatar User",
+ avatar: null,
+ bot: false,
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ )
+ );
+
+ const user = await adapter.getUser("111222");
+ expect(user).not.toBeNull();
+ expect(user?.avatarUrl).toBeUndefined();
+
+ spy.mockRestore();
+ });
+
+ it("should fall back to username when global_name is null", async () => {
+ const adapter = createDiscordAdapter({
+ botToken: "test-token",
+ publicKey: testPublicKey,
+ applicationId: "test-app-id",
+ logger: mockLogger,
+ });
+
+ const spy = vi.spyOn(adapter as any, "discordFetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ id: "333444",
+ username: "fallbackuser",
+ global_name: null,
+ avatar: "def456",
+ bot: false,
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ )
+ );
+
+ const user = await adapter.getUser("333444");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("fallbackuser");
+
+ spy.mockRestore();
+ });
+
+ it("should return isBot true for bot users", async () => {
+ const adapter = createDiscordAdapter({
+ botToken: "test-token",
+ publicKey: testPublicKey,
+ applicationId: "test-app-id",
+ logger: mockLogger,
+ });
+
+ const spy = vi.spyOn(adapter as any, "discordFetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ id: "555666",
+ username: "botuser",
+ global_name: "Bot User",
+ avatar: "ghi789",
+ bot: true,
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ )
+ );
+
+ const user = await adapter.getUser("555666");
+ expect(user).not.toBeNull();
+ expect(user?.isBot).toBe(true);
+
+ spy.mockRestore();
+ });
+
+ it("should call Discord API with correct endpoint and method", async () => {
+ const adapter = createDiscordAdapter({
+ botToken: "test-token",
+ publicKey: testPublicKey,
+ applicationId: "test-app-id",
+ logger: mockLogger,
+ });
+
+ const spy = vi.spyOn(adapter as any, "discordFetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ id: "777888",
+ username: "verifyuser",
+ global_name: "Verify User",
+ avatar: null,
+ bot: false,
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ )
+ );
+
+ await adapter.getUser("777888");
+ expect(spy).toHaveBeenCalledWith("/users/777888", "GET");
+
+ spy.mockRestore();
+ });
+});
diff --git a/packages/adapter-discord/src/index.ts b/packages/adapter-discord/src/index.ts
index a55ad162..e194ebfe 100644
--- a/packages/adapter-discord/src/index.ts
+++ b/packages/adapter-discord/src/index.ts
@@ -29,6 +29,7 @@ import type {
RawMessage,
ThreadInfo,
ThreadSummary,
+ UserInfo,
WebhookOptions,
} from "chat";
import {
@@ -72,6 +73,7 @@ import {
type DiscordRequestContext,
type DiscordSlashCommandContext,
type DiscordThreadId,
+ type DiscordUser,
InteractionResponseType,
} from "./types";
@@ -153,6 +155,25 @@ export class DiscordAdapter implements Adapter {
this.logger.info("Discord adapter initialized");
}
+ async getUser(userId: string): Promise {
+ try {
+ const response = await this.discordFetch(`/users/${userId}`, "GET");
+ const user = (await response.json()) as DiscordUser;
+ return {
+ avatarUrl: user.avatar
+ ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`
+ : undefined,
+ email: undefined,
+ fullName: user.global_name || user.username,
+ isBot: user.bot ?? false,
+ userId: user.id,
+ userName: user.username,
+ };
+ } catch {
+ return null;
+ }
+ }
+
/**
* Handle incoming Discord webhook (HTTP Interactions or forwarded Gateway events).
*/
diff --git a/packages/adapter-gchat/src/index.test.ts b/packages/adapter-gchat/src/index.test.ts
index 29d7f234..d832ca0b 100644
--- a/packages/adapter-gchat/src/index.test.ts
+++ b/packages/adapter-gchat/src/index.test.ts
@@ -2685,6 +2685,7 @@ describe("GoogleChatAdapter", () => {
message: {
name: "spaces/ABC123/messages/msg1",
sender: {
+ avatarUrl: "https://lh3.googleusercontent.com/a/photo.jpg",
name: "users/123456789",
displayName: "John Doe",
type: "HUMAN",
@@ -2702,7 +2703,12 @@ describe("GoogleChatAdapter", () => {
// Verify user info was cached
expect(mockState.set).toHaveBeenCalledWith(
"gchat:user:users/123456789",
- { displayName: "John Doe", email: "john@example.com" },
+ {
+ avatarUrl: "https://lh3.googleusercontent.com/a/photo.jpg",
+ displayName: "John Doe",
+ email: "john@example.com",
+ isBot: false,
+ },
expect.any(Number)
);
});
@@ -3108,4 +3114,84 @@ describe("GoogleChatAdapter", () => {
expect(response.status).toBe(401);
});
});
+
+ describe("getUser", () => {
+ it("should return cached user info", async () => {
+ const { adapter, mockState } = await createInitializedAdapter();
+
+ mockState.storage.set("gchat:user:users/123456", {
+ avatarUrl: "https://lh3.googleusercontent.com/a/alice.jpg",
+ displayName: "Alice Smith",
+ email: "alice@example.com",
+ isBot: false,
+ });
+
+ const user = await adapter.getUser("users/123456");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Alice Smith");
+ expect(user?.userName).toBe("Alice Smith");
+ expect(user?.email).toBe("alice@example.com");
+ expect(user?.avatarUrl).toBe(
+ "https://lh3.googleusercontent.com/a/alice.jpg"
+ );
+ expect(user?.isBot).toBe(false);
+ });
+
+ it("should return null when user not in cache", async () => {
+ const { adapter } = await createInitializedAdapter();
+
+ const user = await adapter.getUser("users/unknown");
+ expect(user).toBeNull();
+ });
+
+ it("should return null when state throws an error", async () => {
+ const { adapter, mockState } = await createInitializedAdapter();
+
+ mockState.get = vi.fn().mockRejectedValue(new Error("State error"));
+
+ const user = await adapter.getUser("users/error");
+ expect(user).toBeNull();
+ });
+
+ it("should return undefined email when user has no email", async () => {
+ const { adapter, mockState } = await createInitializedAdapter();
+
+ mockState.storage.set("gchat:user:users/noemail", {
+ displayName: "No Email User",
+ isBot: false,
+ });
+
+ const user = await adapter.getUser("users/noemail");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("No Email User");
+ expect(user?.email).toBeUndefined();
+ });
+
+ it("should return isBot true for cached bot users", async () => {
+ const { adapter, mockState } = await createInitializedAdapter();
+
+ mockState.storage.set("gchat:user:users/bot123", {
+ displayName: "Bot User",
+ isBot: true,
+ });
+
+ const user = await adapter.getUser("users/bot123");
+ expect(user).not.toBeNull();
+ expect(user?.isBot).toBe(true);
+ });
+
+ it("should return undefined avatarUrl when not cached", async () => {
+ const { adapter, mockState } = await createInitializedAdapter();
+
+ mockState.storage.set("gchat:user:users/avatar-test", {
+ displayName: "Avatar Test",
+ email: "test@example.com",
+ isBot: false,
+ });
+
+ const user = await adapter.getUser("users/avatar-test");
+ expect(user).not.toBeNull();
+ expect(user?.avatarUrl).toBeUndefined();
+ });
+ });
});
diff --git a/packages/adapter-gchat/src/index.ts b/packages/adapter-gchat/src/index.ts
index 77dacd11..5141da94 100644
--- a/packages/adapter-gchat/src/index.ts
+++ b/packages/adapter-gchat/src/index.ts
@@ -27,6 +27,7 @@ import type {
StateAdapter,
ThreadInfo,
ThreadSummary,
+ UserInfo,
WebhookOptions,
} from "chat";
import {
@@ -187,6 +188,7 @@ export interface GoogleChatMessage {
formattedText?: string;
name: string;
sender: {
+ avatarUrl?: string;
name: string;
displayName: string;
type: string;
@@ -714,6 +716,25 @@ export class GoogleChatAdapter implements Adapter {
}
}
+ async getUser(userId: string): Promise {
+ try {
+ const cached = await this.userInfoCache.get(userId);
+ if (!cached) {
+ return null;
+ }
+ return {
+ avatarUrl: cached.avatarUrl,
+ email: cached.email,
+ fullName: cached.displayName,
+ isBot: cached.isBot ?? false,
+ userId,
+ userName: cached.displayName,
+ };
+ } catch {
+ return null;
+ }
+ }
+
async handleWebhook(
request: Request,
options?: WebhookOptions
@@ -1254,7 +1275,13 @@ export class GoogleChatAdapter implements Adapter {
const displayName = message.sender?.displayName || "unknown";
if (userId !== "unknown" && displayName !== "unknown") {
this.userInfoCache
- .set(userId, displayName, message.sender?.email)
+ .set(
+ userId,
+ displayName,
+ message.sender?.email,
+ message.sender?.type === "BOT",
+ message.sender?.avatarUrl
+ )
.catch((error) => {
this.logger.error("Failed to cache user info", { userId, error });
});
diff --git a/packages/adapter-gchat/src/user-info.ts b/packages/adapter-gchat/src/user-info.ts
index 7622f9ba..037618a2 100644
--- a/packages/adapter-gchat/src/user-info.ts
+++ b/packages/adapter-gchat/src/user-info.ts
@@ -14,8 +14,10 @@ const USER_INFO_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
/** Cached user info */
export interface CachedUserInfo {
+ avatarUrl?: string;
displayName: string;
email?: string;
+ isBot?: boolean;
}
/**
@@ -38,13 +40,15 @@ export class UserInfoCache {
async set(
userId: string,
displayName: string,
- email?: string
+ email?: string,
+ isBot?: boolean,
+ avatarUrl?: string
): Promise {
if (!displayName || displayName === "unknown") {
return;
}
- const userInfo: CachedUserInfo = { displayName, email };
+ const userInfo: CachedUserInfo = { avatarUrl, displayName, email, isBot };
// Always update in-memory cache
this.inMemoryCache.set(userId, userInfo);
diff --git a/packages/adapter-github/src/index.test.ts b/packages/adapter-github/src/index.test.ts
index c856c4b3..3b8c5f9e 100644
--- a/packages/adapter-github/src/index.test.ts
+++ b/packages/adapter-github/src/index.test.ts
@@ -28,6 +28,7 @@ const mockReactionsDeleteForIssueComment = vi.fn();
const mockReactionsDeleteForPullRequestComment = vi.fn();
const mockUsersGetAuthenticated = vi.fn();
const mockReposGet = vi.fn();
+const mockRequest = vi.fn();
vi.mock("@octokit/rest", () => {
class MockOctokit {
@@ -62,6 +63,7 @@ vi.mock("@octokit/rest", () => {
repos = {
get: mockReposGet,
};
+ request = mockRequest;
}
return { Octokit: MockOctokit };
});
@@ -2376,3 +2378,134 @@ describe("createGitHubAdapter", () => {
);
});
});
+
+describe("getUser", () => {
+ it("should return user info from GitHub API", async () => {
+ mockRequest.mockResolvedValue({
+ data: {
+ id: 12345,
+ login: "alice",
+ name: "Alice Smith",
+ email: "alice@example.com",
+ avatar_url: "https://avatars.githubusercontent.com/u/12345",
+ type: "User",
+ },
+ });
+
+ const adapter = createGitHubAdapter({
+ token: "ghp_test",
+ webhookSecret: "secret",
+ });
+
+ const user = await adapter.getUser("12345");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Alice Smith");
+ expect(user?.userName).toBe("alice");
+ expect(user?.email).toBe("alice@example.com");
+ expect(user?.avatarUrl).toBe(
+ "https://avatars.githubusercontent.com/u/12345"
+ );
+ expect(user?.isBot).toBe(false);
+ });
+
+ it("should return null on error", async () => {
+ mockRequest.mockRejectedValue(new Error("Not found"));
+
+ const adapter = createGitHubAdapter({
+ token: "ghp_test",
+ webhookSecret: "secret",
+ });
+
+ const user = await adapter.getUser("999999");
+ expect(user).toBeNull();
+ });
+
+ it("should call GitHub API with correct endpoint and params", async () => {
+ mockRequest.mockResolvedValue({
+ data: {
+ id: 12345,
+ login: "alice",
+ name: "Alice Smith",
+ email: null,
+ avatar_url: "https://avatars.githubusercontent.com/u/12345",
+ type: "User",
+ },
+ });
+
+ const adapter = createGitHubAdapter({
+ token: "ghp_test",
+ webhookSecret: "secret",
+ });
+
+ await adapter.getUser("12345");
+ expect(mockRequest).toHaveBeenCalledWith("GET /user/{account_id}", {
+ account_id: Number("12345"),
+ });
+ });
+
+ it("should return isBot true for Bot type users", async () => {
+ mockRequest.mockResolvedValue({
+ data: {
+ id: 99999,
+ login: "dependabot[bot]",
+ name: "Dependabot",
+ email: null,
+ avatar_url: "https://avatars.githubusercontent.com/u/99999",
+ type: "Bot",
+ },
+ });
+
+ const adapter = createGitHubAdapter({
+ token: "ghp_test",
+ webhookSecret: "secret",
+ });
+
+ const user = await adapter.getUser("99999");
+ expect(user).not.toBeNull();
+ expect(user?.isBot).toBe(true);
+ });
+
+ it("should fall back to login when name is null", async () => {
+ mockRequest.mockResolvedValue({
+ data: {
+ id: 55555,
+ login: "noname-user",
+ name: null,
+ email: null,
+ avatar_url: "https://avatars.githubusercontent.com/u/55555",
+ type: "User",
+ },
+ });
+
+ const adapter = createGitHubAdapter({
+ token: "ghp_test",
+ webhookSecret: "secret",
+ });
+
+ const user = await adapter.getUser("55555");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("noname-user");
+ });
+
+ it("should include userId in the response", async () => {
+ mockRequest.mockResolvedValue({
+ data: {
+ id: 12345,
+ login: "alice",
+ name: "Alice Smith",
+ email: "alice@example.com",
+ avatar_url: "https://avatars.githubusercontent.com/u/12345",
+ type: "User",
+ },
+ });
+
+ const adapter = createGitHubAdapter({
+ token: "ghp_test",
+ webhookSecret: "secret",
+ });
+
+ const user = await adapter.getUser("12345");
+ expect(user).not.toBeNull();
+ expect(user?.userId).toBe("12345");
+ });
+});
diff --git a/packages/adapter-github/src/index.ts b/packages/adapter-github/src/index.ts
index 1c5278da..6dd68986 100644
--- a/packages/adapter-github/src/index.ts
+++ b/packages/adapter-github/src/index.ts
@@ -20,6 +20,7 @@ import type {
StreamOptions,
Thread,
ThreadInfo,
+ UserInfo,
WebhookOptions,
} from "chat";
import { ConsoleLogger, convertEmojiPlaceholders, Message } from "chat";
@@ -378,6 +379,26 @@ export class GitHubAdapter
return this.getStoredInstallationId(owner, repo);
}
+ async getUser(userId: string): Promise {
+ try {
+ const { data: user } = await this.getOctokit().request(
+ "GET /user/{account_id}",
+ { account_id: Number(userId) }
+ );
+ return {
+ avatarUrl: user.avatar_url,
+ email: user.email ?? undefined,
+ fullName: user.name || user.login,
+ isBot: user.type === "Bot",
+ userId: String(user.id),
+ userName: user.login,
+ };
+ } catch (error) {
+ this.logger.debug("Failed to fetch user", { userId, error });
+ return null;
+ }
+ }
+
/**
* Handle incoming webhook from GitHub.
*/
diff --git a/packages/adapter-linear/src/index.test.ts b/packages/adapter-linear/src/index.test.ts
index 67e1b6d4..b5dca59f 100644
--- a/packages/adapter-linear/src/index.test.ts
+++ b/packages/adapter-linear/src/index.test.ts
@@ -3706,3 +3706,86 @@ describe("createLinearAdapter", () => {
);
});
});
+
+describe("getUser", () => {
+ it("should return user info from Linear API", async () => {
+ const adapter = createWebhookAdapter();
+ (adapter as any).defaultClient = {
+ user: vi.fn().mockResolvedValue({
+ id: "user-123",
+ name: "Alice Smith",
+ displayName: "alice",
+ email: "alice@example.com",
+ avatarUrl: "https://example.com/alice.png",
+ }),
+ };
+
+ const user = await adapter.getUser("user-123");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Alice Smith");
+ expect(user?.userName).toBe("alice");
+ expect(user?.email).toBe("alice@example.com");
+ expect(user?.avatarUrl).toBe("https://example.com/alice.png");
+ expect(user?.isBot).toBe(false);
+ });
+
+ it("should return null on error", async () => {
+ const adapter = createWebhookAdapter();
+ (adapter as any).defaultClient = {
+ user: vi.fn().mockRejectedValue(new Error("Not found")),
+ };
+
+ const user = await adapter.getUser("unknown");
+ expect(user).toBeNull();
+ });
+
+ it("should include userId in the response", async () => {
+ const adapter = createWebhookAdapter();
+ (adapter as any).defaultClient = {
+ user: vi.fn().mockResolvedValue({
+ id: "user-123",
+ name: "Alice Smith",
+ displayName: "alice",
+ email: "alice@example.com",
+ avatarUrl: "https://example.com/alice.png",
+ }),
+ };
+
+ const user = await adapter.getUser("user-123");
+ expect(user).not.toBeNull();
+ expect(user?.userId).toBe("user-123");
+ });
+
+ it("should call Linear SDK with the correct user ID", async () => {
+ const adapter = createWebhookAdapter();
+ const userMock = vi.fn().mockResolvedValue({
+ id: "user-123",
+ name: "Alice Smith",
+ displayName: "alice",
+ email: "alice@example.com",
+ avatarUrl: "https://example.com/alice.png",
+ });
+ (adapter as any).defaultClient = { user: userMock };
+
+ await adapter.getUser("user-123");
+ expect(userMock).toHaveBeenCalledWith("user-123");
+ });
+
+ it("should return undefined for null optional fields", async () => {
+ const adapter = createWebhookAdapter();
+ (adapter as any).defaultClient = {
+ user: vi.fn().mockResolvedValue({
+ id: "user-456",
+ name: "Bob",
+ displayName: "bob",
+ email: null,
+ avatarUrl: null,
+ }),
+ };
+
+ const user = await adapter.getUser("user-456");
+ expect(user).not.toBeNull();
+ expect(user?.email).toBeUndefined();
+ expect(user?.avatarUrl).toBeUndefined();
+ });
+});
diff --git a/packages/adapter-linear/src/index.ts b/packages/adapter-linear/src/index.ts
index 961da795..d09acb08 100644
--- a/packages/adapter-linear/src/index.ts
+++ b/packages/adapter-linear/src/index.ts
@@ -25,6 +25,7 @@ import type {
StreamChunk,
StreamOptions,
ThreadInfo,
+ UserInfo,
WebhookOptions,
} from "chat";
import { ConsoleLogger, Message, StreamingMarkdownRenderer } from "chat";
@@ -445,6 +446,23 @@ export class LinearAdapter
this.logger.info("Linear installation deleted", { organizationId });
}
+ async getUser(userId: string): Promise {
+ try {
+ await this.ensureValidToken();
+ const user = await this.getClient().user(userId);
+ return {
+ avatarUrl: user.avatarUrl ?? undefined,
+ email: user.email ?? undefined,
+ fullName: user.name,
+ isBot: false,
+ userId: user.id,
+ userName: user.displayName,
+ };
+ } catch {
+ return null;
+ }
+ }
+
/**
* Handle the Linear OAuth callback.
* Accepts the incoming request, extracts the authorization code,
diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts
index 04cee9fc..c6fd21cd 100644
--- a/packages/adapter-slack/src/index.test.ts
+++ b/packages/adapter-slack/src/index.test.ts
@@ -6655,3 +6655,210 @@ describe("scheduleMessage with empty threadTs", () => {
);
});
});
+describe("getUser", () => {
+ it("should return user info with email and avatar", async () => {
+ const state = createMockState();
+ const chatInstance = createMockChatInstance(state);
+ const adapter = createSlackAdapter({
+ botToken: "xoxb-test-token",
+ signingSecret: "test-secret",
+ logger: mockLogger,
+ });
+ await adapter.initialize(chatInstance);
+
+ mockClientMethod(
+ adapter,
+ "users.info",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ user: {
+ is_bot: false,
+ name: "alice",
+ real_name: "Alice Smith",
+ profile: {
+ display_name: "Alice",
+ real_name: "Alice Smith",
+ email: "alice@example.com",
+ image_192: "https://example.com/alice.png",
+ },
+ },
+ })
+ );
+
+ const user = await adapter.getUser("U123456");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Alice Smith");
+ expect(user?.userName).toBe("Alice");
+ expect(user?.email).toBe("alice@example.com");
+ expect(user?.avatarUrl).toBe("https://example.com/alice.png");
+ expect(user?.isBot).toBe(false);
+ expect(user?.userId).toBe("U123456");
+ });
+
+ it("should return null when API fails", async () => {
+ const state = createMockState();
+ const chatInstance = createMockChatInstance(state);
+ const adapter = createSlackAdapter({
+ botToken: "xoxb-test-token",
+ signingSecret: "test-secret",
+ logger: mockLogger,
+ });
+ await adapter.initialize(chatInstance);
+
+ mockClientMethod(
+ adapter,
+ "users.info",
+ vi.fn().mockRejectedValue(new Error("API error"))
+ );
+
+ const user = await adapter.getUser("U_UNKNOWN");
+ expect(user).toBeNull();
+ });
+
+ it("should return null when user not found", async () => {
+ const state = createMockState();
+ const chatInstance = createMockChatInstance(state);
+ const adapter = createSlackAdapter({
+ botToken: "xoxb-test-token",
+ signingSecret: "test-secret",
+ logger: mockLogger,
+ });
+ await adapter.initialize(chatInstance);
+
+ mockClientMethod(
+ adapter,
+ "users.info",
+ vi.fn().mockRejectedValue(new Error("user_not_found"))
+ );
+
+ const user = await adapter.getUser("U_NOTFOUND");
+ expect(user).toBeNull();
+ });
+
+ it("should return isBot true for bot users", async () => {
+ const state = createMockState();
+ const chatInstance = createMockChatInstance(state);
+ const adapter = createSlackAdapter({
+ botToken: "xoxb-test-token",
+ signingSecret: "test-secret",
+ logger: mockLogger,
+ });
+ await adapter.initialize(chatInstance);
+
+ mockClientMethod(
+ adapter,
+ "users.info",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ user: {
+ is_bot: true,
+ name: "mybot",
+ real_name: "My Bot",
+ profile: {
+ display_name: "Bot",
+ real_name: "My Bot",
+ image_192: "https://example.com/bot.png",
+ },
+ },
+ })
+ );
+
+ const user = await adapter.getUser("UBOT123");
+ expect(user).not.toBeNull();
+ expect(user?.isBot).toBe(true);
+ });
+
+ it("should fall through to real_name when display_name is empty", async () => {
+ const state = createMockState();
+ const chatInstance = createMockChatInstance(state);
+ const adapter = createSlackAdapter({
+ botToken: "xoxb-test-token",
+ signingSecret: "test-secret",
+ logger: mockLogger,
+ });
+ await adapter.initialize(chatInstance);
+
+ mockClientMethod(
+ adapter,
+ "users.info",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ user: {
+ is_bot: false,
+ name: "alice",
+ real_name: "Alice Smith",
+ profile: {
+ display_name: "",
+ real_name: "Alice Smith",
+ email: "alice@example.com",
+ image_192: "https://example.com/alice.png",
+ },
+ },
+ })
+ );
+
+ const user = await adapter.getUser("U_PARTIAL");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Alice Smith");
+ expect(user?.userName).toBe("Alice Smith");
+ });
+
+ it("should call users.info with correct user ID", async () => {
+ const state = createMockState();
+ const chatInstance = createMockChatInstance(state);
+ const adapter = createSlackAdapter({
+ botToken: "xoxb-test-token",
+ signingSecret: "test-secret",
+ logger: mockLogger,
+ });
+ await adapter.initialize(chatInstance);
+
+ const usersInfoMock = vi.fn().mockResolvedValue({
+ ok: true,
+ user: {
+ is_bot: false,
+ name: "alice",
+ real_name: "Alice",
+ profile: {
+ display_name: "Alice",
+ real_name: "Alice",
+ },
+ },
+ });
+ mockClientMethod(adapter, "users.info", usersInfoMock);
+
+ await adapter.getUser("U_VERIFY");
+ expect(usersInfoMock).toHaveBeenCalledWith(
+ expect.objectContaining({ user: "U_VERIFY" })
+ );
+ });
+
+ it("should return cached user without hitting API", async () => {
+ const state = createMockState();
+ const chatInstance = createMockChatInstance(state);
+ const adapter = createSlackAdapter({
+ botToken: "xoxb-test-token",
+ signingSecret: "test-secret",
+ logger: mockLogger,
+ });
+ await adapter.initialize(chatInstance);
+
+ state.cache.set("slack:user:U_CACHED", {
+ avatarUrl: "https://example.com/cached.png",
+ displayName: "Cached User",
+ email: "cached@example.com",
+ isBot: false,
+ realName: "Cached User Full",
+ });
+
+ const usersInfoMock = vi.fn();
+ mockClientMethod(adapter, "users.info", usersInfoMock);
+
+ const user = await adapter.getUser("U_CACHED");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Cached User Full");
+ expect(user?.userName).toBe("Cached User");
+ expect(user?.email).toBe("cached@example.com");
+ expect(usersInfoMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts
index d00b72d9..486b1625 100644
--- a/packages/adapter-slack/src/index.ts
+++ b/packages/adapter-slack/src/index.ts
@@ -44,6 +44,7 @@ import type {
StreamOptions,
ThreadInfo,
ThreadSummary,
+ UserInfo,
WebhookOptions,
} from "chat";
@@ -454,7 +455,10 @@ type SlackInteractivePayload =
/** Cached user info */
interface CachedUser {
+ avatarUrl?: string;
displayName: string;
+ email?: string;
+ isBot?: boolean;
realName: string;
}
@@ -853,18 +857,16 @@ export class SlackAdapter implements Adapter {
/**
* Look up user info from Slack API with caching via state adapter.
- * Returns display name and real name, or falls back to user ID.
+ * Returns null when the API call fails.
*/
- private async lookupUser(
- userId: string
- ): Promise<{ displayName: string; realName: string }> {
+ private async lookupUser(userId: string): Promise {
const cacheKey = `slack:user:${userId}`;
// Check cache first (via state adapter for serverless compatibility)
if (this.chat) {
const cached = await this.chat.getState().get(cacheKey);
if (cached) {
- return { displayName: cached.displayName, realName: cached.realName };
+ return cached;
}
}
@@ -873,9 +875,15 @@ export class SlackAdapter implements Adapter {
await this.withToken({ user: userId })
);
const user = result.user as {
+ is_bot?: boolean;
name?: string;
+ profile?: {
+ display_name?: string;
+ email?: string;
+ image_192?: string;
+ real_name?: string;
+ };
real_name?: string;
- profile?: { display_name?: string; real_name?: string };
};
// Slack user naming: profile.display_name > profile.real_name > real_name > name > userId
@@ -888,15 +896,19 @@ export class SlackAdapter implements Adapter {
const realName =
user?.real_name || user?.profile?.real_name || displayName;
+ const cached: CachedUser = {
+ avatarUrl: user?.profile?.image_192,
+ displayName,
+ email: user?.profile?.email,
+ isBot: user?.is_bot,
+ realName,
+ };
+
// Cache the result via state adapter
if (this.chat) {
await this.chat
.getState()
- .set(
- cacheKey,
- { displayName, realName },
- SlackAdapter.USER_CACHE_TTL_MS
- );
+ .set(cacheKey, cached, SlackAdapter.USER_CACHE_TTL_MS);
// Build reverse index: display name → user IDs (skip if already present)
const normalizedName = displayName.toLowerCase();
@@ -915,11 +927,10 @@ export class SlackAdapter implements Adapter {
displayName,
realName,
});
- return { displayName, realName };
+ return cached;
} catch (error) {
this.logger.warn("Could not fetch user info", { userId, error });
- // Fall back to user ID
- return { displayName: userId, realName: userId };
+ return null;
}
}
@@ -965,6 +976,25 @@ export class SlackAdapter implements Adapter {
}
}
+ async getUser(userId: string): Promise {
+ try {
+ const cached = await this.lookupUser(userId);
+ if (!cached) {
+ return null;
+ }
+ return {
+ avatarUrl: cached.avatarUrl,
+ email: cached.email,
+ fullName: cached.realName,
+ isBot: cached.isBot ?? false,
+ userId,
+ userName: cached.displayName,
+ };
+ } catch {
+ return null;
+ }
+ }
+
async handleWebhook(
request: Request,
options?: WebhookOptions
@@ -1248,8 +1278,8 @@ export class SlackAdapter implements Adapter {
text,
user: {
userId,
- userName: userInfo.displayName,
- fullName: userInfo.realName,
+ userName: userInfo?.displayName ?? userId,
+ fullName: userInfo?.realName ?? userId,
isBot: false,
isMe: false,
},
@@ -2384,7 +2414,7 @@ export class SlackAdapter implements Adapter {
Promise.all(
[...userIds].map(async (uid) => {
const info = await this.lookupUser(uid);
- return [uid, info.displayName] as const;
+ return [uid, info?.displayName ?? uid] as const;
})
),
Promise.all(
@@ -2524,8 +2554,8 @@ export class SlackAdapter implements Adapter {
// If we have a user ID but no username, look up the user info
if (event.user && !event.username) {
const userInfo = await this.lookupUser(event.user);
- userName = userInfo.displayName;
- fullName = userInfo.realName;
+ userName = userInfo?.displayName ?? event.user;
+ fullName = userInfo?.realName ?? userName;
}
// Track thread participants for outgoing mention resolution (skip dupes)
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..5abd8817 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,43 @@ 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
diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts
index 48503429..46944f62 100644
--- a/packages/adapter-telegram/src/index.test.ts
+++ b/packages/adapter-telegram/src/index.test.ts
@@ -2355,3 +2355,164 @@ describe("applyTelegramEntities", () => {
);
});
});
+
+describe("getUser", () => {
+ it("should return user info from Telegram getChat", async () => {
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ });
+
+ // getMe for initialize
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ );
+ await adapter.initialize(createMockChat());
+
+ // getChat for getUser
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 456,
+ first_name: "Alice",
+ last_name: "Smith",
+ username: "alicesmith",
+ type: "private",
+ })
+ );
+
+ const user = await adapter.getUser("456");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Alice Smith");
+ expect(user?.userName).toBe("alicesmith");
+ expect(user?.userId).toBe("456");
+ expect(user?.isBot).toBe(false);
+ expect(user?.email).toBeUndefined();
+ });
+
+ it("should return null on error", async () => {
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ });
+
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ );
+ await adapter.initialize(createMockChat());
+
+ mockFetch.mockResolvedValueOnce(telegramError(400, 400, "Bad Request"));
+
+ const user = await adapter.getUser("unknown");
+ expect(user).toBeNull();
+ });
+
+ it("should return null for group/channel chat IDs", async () => {
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ });
+
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ );
+ await adapter.initialize(createMockChat());
+
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: -100123,
+ type: "group",
+ title: "Test Group",
+ })
+ );
+
+ const user = await adapter.getUser("-100123");
+ expect(user).toBeNull();
+ });
+
+ it("should handle first-name only user (no last_name or username)", async () => {
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ });
+
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ );
+ await adapter.initialize(createMockChat());
+
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 789,
+ first_name: "Charlie",
+ type: "private",
+ })
+ );
+
+ const user = await adapter.getUser("789");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Charlie");
+ expect(user?.userName).toBe("Charlie");
+ });
+
+ it("should call Telegram API with correct method and params", async () => {
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ });
+
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ );
+ await adapter.initialize(createMockChat());
+
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 456,
+ first_name: "Alice",
+ username: "alice",
+ type: "private",
+ })
+ );
+
+ await adapter.getUser("456");
+
+ // The second fetch call (index 1) is the getChat call
+ const getChatUrl = String(mockFetch.mock.calls[1]?.[0]);
+ expect(getChatUrl).toContain("/getChat");
+
+ const getChatBody = JSON.parse(
+ String((mockFetch.mock.calls[1]?.[1] as RequestInit).body)
+ ) as { chat_id: string };
+ expect(getChatBody.chat_id).toBe("456");
+ });
+});
diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts
index 6678a5a9..88745966 100644
--- a/packages/adapter-telegram/src/index.ts
+++ b/packages/adapter-telegram/src/index.ts
@@ -23,6 +23,7 @@ import type {
Logger,
RawMessage,
ThreadInfo,
+ UserInfo,
WebhookOptions,
} from "chat";
import {
@@ -319,6 +320,32 @@ export class TelegramAdapter
}
}
+ async getUser(userId: string): Promise {
+ try {
+ const chat = await this.telegramFetch("getChat", {
+ chat_id: userId,
+ });
+ // Only private chats represent users — groups/channels are not user lookups
+ if (chat.type !== "private") {
+ return null;
+ }
+ const fullName = [chat.first_name, chat.last_name]
+ .filter(Boolean)
+ .join(" ");
+ return {
+ email: undefined,
+ fullName: fullName || String(chat.id),
+ // Telegram's getChat API doesn't expose is_bot (only available on TelegramUser).
+ // Always returns false — callers needing bot detection should use message.author.isBot instead.
+ isBot: false,
+ userId: String(chat.id),
+ userName: chat.username || chat.first_name || String(chat.id),
+ };
+ } catch {
+ return null;
+ }
+ }
+
async handleWebhook(
request: Request,
options?: WebhookOptions
diff --git a/packages/chat/src/chat.test.ts b/packages/chat/src/chat.test.ts
index 28471a43..ac2708ce 100644
--- a/packages/chat/src/chat.test.ts
+++ b/packages/chat/src/chat.test.ts
@@ -1599,6 +1599,173 @@ describe("Chat", () => {
});
});
+ describe("getUser", () => {
+ it("should return user info from adapter", async () => {
+ mockAdapter.getUser = vi.fn().mockResolvedValue({
+ userId: "U123456",
+ userName: "alice",
+ fullName: "Alice Smith",
+ email: "alice@example.com",
+ avatarUrl: "https://example.com/alice.png",
+ isBot: false,
+ });
+
+ const user = await chat.getUser("U123456");
+ expect(user).not.toBeNull();
+ expect(user?.email).toBe("alice@example.com");
+ expect(user?.fullName).toBe("Alice Smith");
+ expect(mockAdapter.getUser).toHaveBeenCalledWith("U123456");
+ });
+
+ it("should accept Author object", async () => {
+ mockAdapter.getUser = vi.fn().mockResolvedValue({
+ userId: "U789",
+ userName: "bob",
+ fullName: "Bob Jones",
+ isBot: false,
+ });
+
+ const user = await chat.getUser({
+ userId: "U789",
+ userName: "bob",
+ fullName: "Bob Jones",
+ isBot: false,
+ isMe: false,
+ });
+ expect(mockAdapter.getUser).toHaveBeenCalledWith("U789");
+ expect(user?.fullName).toBe("Bob Jones");
+ });
+
+ it("should throw when adapter does not support getUser", async () => {
+ await expect(chat.getUser("U123456")).rejects.toThrow(
+ "does not support getUser"
+ );
+ });
+
+ it("should return null when user is not found", async () => {
+ mockAdapter.getUser = vi.fn().mockResolvedValue(null);
+ const user = await chat.getUser("U999999");
+ expect(user).toBeNull();
+ });
+
+ it("should throw error for unknown userId format", async () => {
+ mockAdapter.getUser = vi.fn().mockResolvedValue(null);
+ await expect(chat.getUser("invalid-user-id")).rejects.toThrow(
+ 'Cannot infer adapter from userId "invalid-user-id"'
+ );
+ });
+
+ it("should infer linear adapter from a UUID", async () => {
+ const linearAdapter = createMockAdapter("linear");
+ linearAdapter.getUser = vi.fn().mockResolvedValue({
+ userId: "8f1f3c7e-d4e1-4f9a-bf2b-1c3d4e5f6a7b",
+ userName: "ben",
+ fullName: "Ben Sabic",
+ isBot: false,
+ });
+ const multi = new Chat({
+ userName: "testbot",
+ adapters: { slack: mockAdapter, linear: linearAdapter },
+ state: createMockState(),
+ logger: mockLogger,
+ });
+
+ const user = await multi.getUser("8f1f3c7e-d4e1-4f9a-bf2b-1c3d4e5f6a7b");
+ expect(user?.fullName).toBe("Ben Sabic");
+ expect(linearAdapter.getUser).toHaveBeenCalledWith(
+ "8f1f3c7e-d4e1-4f9a-bf2b-1c3d4e5f6a7b"
+ );
+ });
+
+ it("should infer telegram from numeric id when only telegram is registered", async () => {
+ const telegramAdapter = createMockAdapter("telegram");
+ telegramAdapter.getUser = vi.fn().mockResolvedValue({
+ userId: "987654321",
+ userName: "alice",
+ fullName: "Alice",
+ isBot: false,
+ });
+ const multi = new Chat({
+ userName: "testbot",
+ adapters: { telegram: telegramAdapter },
+ state: createMockState(),
+ logger: mockLogger,
+ });
+
+ const user = await multi.getUser("987654321");
+ expect(user?.userName).toBe("alice");
+ expect(telegramAdapter.getUser).toHaveBeenCalledWith("987654321");
+ });
+
+ it("should infer github from numeric id when only github is registered", async () => {
+ const githubAdapter = createMockAdapter("github");
+ githubAdapter.getUser = vi.fn().mockResolvedValue({
+ userId: "12345",
+ userName: "octocat",
+ fullName: "The Octocat",
+ isBot: false,
+ });
+ const multi = new Chat({
+ userName: "testbot",
+ adapters: { github: githubAdapter },
+ state: createMockState(),
+ logger: mockLogger,
+ });
+
+ const user = await multi.getUser("12345");
+ expect(user?.userName).toBe("octocat");
+ });
+
+ it("should infer discord for 17-19 digit snowflake when only discord is registered", async () => {
+ const discordAdapter = createMockAdapter("discord");
+ discordAdapter.getUser = vi.fn().mockResolvedValue({
+ userId: "175928847299117063",
+ userName: "discordbot",
+ fullName: "Discord User",
+ isBot: false,
+ });
+ const multi = new Chat({
+ userName: "testbot",
+ adapters: { discord: discordAdapter },
+ state: createMockState(),
+ logger: mockLogger,
+ });
+
+ const user = await multi.getUser("175928847299117063");
+ expect(user?.fullName).toBe("Discord User");
+ });
+
+ it("should throw AMBIGUOUS_USER_ID when numeric id matches multiple registered adapters", async () => {
+ const discordAdapter = createMockAdapter("discord");
+ const telegramAdapter = createMockAdapter("telegram");
+ const multi = new Chat({
+ userName: "testbot",
+ adapters: { discord: discordAdapter, telegram: telegramAdapter },
+ state: createMockState(),
+ logger: mockLogger,
+ });
+
+ await expect(multi.getUser("175928847299117063")).rejects.toThrow(
+ "ambiguous"
+ );
+ });
+
+ it("should not match GitHub-style logins as Slack ids (case sensitivity)", async () => {
+ // "user123" used to match the case-insensitive Slack regex; now must not.
+ const githubAdapter = createMockAdapter("github");
+ const multi = new Chat({
+ userName: "testbot",
+ adapters: { slack: mockAdapter, github: githubAdapter },
+ state: createMockState(),
+ logger: mockLogger,
+ });
+
+ await expect(multi.getUser("user123")).rejects.toThrow(
+ 'Cannot infer adapter from userId "user123"'
+ );
+ });
+ });
+
describe("isDM", () => {
it("should return true for DM threads", async () => {
const thread = await chat.openDM("U123456");
diff --git a/packages/chat/src/chat.ts b/packages/chat/src/chat.ts
index 18c6be68..0c92478a 100644
--- a/packages/chat/src/chat.ts
+++ b/packages/chat/src/chat.ts
@@ -56,6 +56,7 @@ import type {
StateAdapter,
SubscribedMessageHandler,
Thread,
+ UserInfo,
WebhookOptions,
} from "./types";
import { ChatError, ConsoleLogger, LockError } from "./types";
@@ -66,8 +67,11 @@ const DEFAULT_LOCK_TTL_MS = 30_000; // 30 seconds
function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
-const SLACK_USER_ID_REGEX = /^U[A-Z0-9]+$/i;
+const SLACK_USER_ID_REGEX = /^[UW][A-Z0-9]+$/;
const DISCORD_SNOWFLAKE_REGEX = /^\d{17,19}$/;
+const LINEAR_UUID_REGEX =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const NUMERIC_REGEX = /^\d+$/;
/** TTL for message deduplication entries */
const DEDUPE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const MODAL_CONTEXT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -1612,6 +1616,35 @@ export class Chat<
return this.createThread(adapter, threadId, {} as Message, false);
}
+ /**
+ * Look up user information by user ID.
+ *
+ * The adapter is automatically inferred from the user ID format.
+ * Returns user details including email (where available — requires
+ * appropriate scopes on some platforms, e.g. `users:read.email` on Slack).
+ *
+ * @param user - Platform-specific user ID string, or an Author object
+ * @returns User info, or null if user not found
+ *
+ * @example
+ * ```typescript
+ * const user = await chat.getUser("U123456");
+ * console.log(user?.email); // "alice@company.com"
+ * ```
+ */
+ async getUser(user: string | Author): Promise {
+ const userId = typeof user === "string" ? user : user.userId;
+ const adapter = this.inferAdapterFromUserId(userId);
+ if (!adapter.getUser) {
+ throw new ChatError(
+ `Adapter "${adapter.name}" does not support getUser`,
+ "NOT_SUPPORTED"
+ );
+ }
+
+ return adapter.getUser(userId);
+ }
+
/**
* Get a Channel by its channel ID.
*
@@ -1700,7 +1733,9 @@ export class Chat<
* Infer which adapter to use based on the userId format.
*/
private inferAdapterFromUserId(userId: string): Adapter {
- // Google Chat: users/123456789
+ // Unique-prefix formats — no collision possible across adapters
+
+ // Google Chat: "users/123456789"
if (userId.startsWith("users/")) {
const adapter = this.adapters.get("gchat");
if (adapter) {
@@ -1708,7 +1743,7 @@ export class Chat<
}
}
- // Teams: 29:base64string...
+ // Teams: "29:base64string..."
if (userId.startsWith("29:")) {
const adapter = this.adapters.get("teams");
if (adapter) {
@@ -1716,24 +1751,57 @@ export class Chat<
}
}
- // Slack: U followed by alphanumeric (e.g., U00FAKEUSER1)
- if (SLACK_USER_ID_REGEX.test(userId)) {
- const adapter = this.adapters.get("slack");
+ // Linear: UUID v4 (e.g., "8f1f3c7e-d4e1-4f9a-bf2b-1c3d4e5f6a7b")
+ if (LINEAR_UUID_REGEX.test(userId)) {
+ const adapter = this.adapters.get("linear");
if (adapter) {
return adapter;
}
}
- // Discord: snowflake ID (17-19 digit number)
- if (DISCORD_SNOWFLAKE_REGEX.test(userId)) {
- const adapter = this.adapters.get("discord");
+ // Slack: "U..." or "W..." (uppercase, alphanumeric, 7+ chars total)
+ // — never lowercase, so won't collide with GitHub logins like "user123"
+ if (SLACK_USER_ID_REGEX.test(userId)) {
+ const adapter = this.adapters.get("slack");
if (adapter) {
return adapter;
}
}
+ // Numeric IDs are shared by Discord (17-19 digit snowflakes), Telegram
+ // (positive integer up to 52 bits), and GitHub (numeric account_id).
+ // Disambiguate by which adapters the caller actually registered.
+ if (NUMERIC_REGEX.test(userId)) {
+ const candidates: string[] = [];
+ if (
+ DISCORD_SNOWFLAKE_REGEX.test(userId) &&
+ this.adapters.has("discord")
+ ) {
+ candidates.push("discord");
+ }
+ if (this.adapters.has("telegram")) {
+ candidates.push("telegram");
+ }
+ if (this.adapters.has("github")) {
+ candidates.push("github");
+ }
+
+ if (candidates.length === 1) {
+ const adapter = this.adapters.get(candidates[0] as string);
+ if (adapter) {
+ return adapter;
+ }
+ }
+ if (candidates.length > 1) {
+ throw new ChatError(
+ `Numeric userId "${userId}" is ambiguous between adapters: ${candidates.join(", ")}. Call the platform's adapter directly (e.g. \`adapter.getUser(userId)\`).`,
+ "AMBIGUOUS_USER_ID"
+ );
+ }
+ }
+
throw new ChatError(
- `Cannot infer adapter from userId "${userId}". Expected format: Slack (U...), Teams (29:...), Google Chat (users/...), or Discord (numeric snowflake).`,
+ `Cannot infer adapter from userId "${userId}". Expected: Slack ("U..."), Teams ("29:..."), Google Chat ("users/..."), Linear (UUID), or Discord/Telegram/GitHub (numeric).`,
"UNKNOWN_USER_ID_FORMAT"
);
}
diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts
index 9f5f1584..a79fa364 100644
--- a/packages/chat/src/index.ts
+++ b/packages/chat/src/index.ts
@@ -373,6 +373,7 @@ export type {
Thread,
ThreadInfo,
ThreadSummary,
+ UserInfo,
WebhookOptions,
WellKnownEmoji,
} from "./types";
diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts
index 71cfbb5e..cfb94954 100644
--- a/packages/chat/src/types.ts
+++ b/packages/chat/src/types.ts
@@ -316,6 +316,15 @@ export interface Adapter {
*/
getChannelVisibility?(threadId: string): ChannelVisibility;
+ /**
+ * Look up user information by user ID.
+ * Optional — not all platforms support this.
+ *
+ * @param userId - Platform-specific user ID
+ * @returns User info, or null if user not found
+ */
+ getUser?(userId: string): Promise;
+
/** Handle incoming webhook request */
handleWebhook(request: Request, options?: WebhookOptions): Promise;
@@ -1357,6 +1366,22 @@ export interface Author {
userName: string;
}
+/** User information returned by adapter.getUser() */
+export interface UserInfo {
+ /** URL to the user's avatar/profile image */
+ avatarUrl?: string;
+ /** User's email address (requires appropriate scopes on some platforms) */
+ email?: string;
+ /** User's display name / full name */
+ fullName: string;
+ /** Whether the user is a bot */
+ isBot: boolean;
+ /** Platform-specific user ID */
+ userId: string;
+ /** Username/handle */
+ userName: string;
+}
+
export interface MessageMetadata {
/** When the message was sent */
dateSent: Date;