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/apps/docs/content/docs/api/chat.mdx b/apps/docs/content/docs/api/chat.mdx index 977225dd..f337c62b 100644 --- a/apps/docs/content/docs/api/chat.mdx +++ b/apps/docs/content/docs/api/chat.mdx @@ -443,6 +443,71 @@ 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, 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); +``` + + + + + Email availability varies by platform. On Slack it requires the `users:read.email` scope. Discord and Telegram don't expose email to bots. Fields that aren't available return `undefined`. + + +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 d4e90b80..ae3e48d7 100644 --- a/packages/adapter-discord/src/index.test.ts +++ b/packages/adapter-discord/src/index.test.ts @@ -4062,3 +4062,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 7884de9e..a39f013e 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 357cedd1..2b2b7aa3 100644 --- a/packages/adapter-gchat/src/index.test.ts +++ b/packages/adapter-gchat/src/index.test.ts @@ -2585,6 +2585,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", @@ -2602,7 +2603,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) ); }); @@ -3008,4 +3014,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 8721b89e..1d368a15 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; @@ -705,6 +707,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 @@ -1235,7 +1256,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 4e6cda8e..3262a236 100644 --- a/packages/adapter-linear/src/index.test.ts +++ b/packages/adapter-linear/src/index.test.ts @@ -3705,3 +3705,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 41e593f8..1f138209 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -5190,3 +5190,211 @@ describe("reverse user lookup", () => { }); }); }); + +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 784a242a..11fbec5b 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -40,6 +40,7 @@ import type { StreamOptions, ThreadInfo, ThreadSummary, + UserInfo, WebhookOptions, } from "chat"; @@ -363,7 +364,10 @@ type SlackInteractivePayload = /** Cached user info */ interface CachedUser { + avatarUrl?: string; displayName: string; + email?: string; + isBot?: boolean; realName: string; } @@ -721,18 +725,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; } } @@ -741,9 +743,15 @@ export class SlackAdapter implements Adapter { 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 @@ -756,15 +764,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(); @@ -783,11 +795,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; } } @@ -833,6 +844,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 @@ -1044,8 +1074,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, }, @@ -1773,7 +1803,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( @@ -1913,8 +1943,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-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index dfb8b28f..f7a5a232 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -1980,3 +1980,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 eb0e263d..771c5a67 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 { @@ -311,6 +312,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 e02dcfab..1d69c7aa 100644 --- a/packages/chat/src/chat.test.ts +++ b/packages/chat/src/chat.test.ts @@ -1450,6 +1450,63 @@ 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"' + ); + }); + }); + 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 5cac0b93..4b5949db 100644 --- a/packages/chat/src/chat.ts +++ b/packages/chat/src/chat.ts @@ -53,6 +53,7 @@ import type { StateAdapter, SubscribedMessageHandler, Thread, + UserInfo, WebhookOptions, } from "./types"; import { ChatError, ConsoleLogger, LockError } from "./types"; @@ -1525,6 +1526,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. * diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index fc27fa95..c9b0861d 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -354,6 +354,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 d17e7c65..ed4e8a2f 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; @@ -1341,6 +1350,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;