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;