diff --git a/.changeset/add-author-field-and-scope.md b/.changeset/add-author-field-and-scope.md new file mode 100644 index 00000000..64de7f0e --- /dev/null +++ b/.changeset/add-author-field-and-scope.md @@ -0,0 +1,6 @@ +--- +"chat": minor +"@chat-adapter/slack": minor +--- + +Add new field on Author in core types and a new scope for Slack. diff --git a/examples/nextjs-chat/slack-manifest.yml b/examples/nextjs-chat/slack-manifest.yml index 2df83dde..c15b21fc 100644 --- a/examples/nextjs-chat/slack-manifest.yml +++ b/examples/nextjs-chat/slack-manifest.yml @@ -26,6 +26,8 @@ oauth_config: - reactions:write # User info for display names - users:read + # Optional: required to fetch user emails (message.author.email) + # - users:read.email settings: event_subscriptions: diff --git a/packages/adapter-slack/README.md b/packages/adapter-slack/README.md index 13971cf9..419652da 100644 --- a/packages/adapter-slack/README.md +++ b/packages/adapter-slack/README.md @@ -132,6 +132,8 @@ oauth_config: - reactions:read - reactions:write - users:read + # Optional: required only for fetching user emails (message.author.email) + # - users:read.email settings: event_subscriptions: @@ -153,6 +155,8 @@ settings: token_rotation_enabled: false ``` +> **Note on optional scopes**: To fetch user emails (available on the `email` property of `message.author`), you must add `users:read.email` to the `bot` scopes above. Existing app installations will need to be re-installed to grant this new scope if you add it later. + 4. Replace `https://your-domain.com/api/webhooks/slack` with your deployed webhook URL 5. Click **Create** diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 91cd0989..62fe0ff0 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -3107,6 +3107,102 @@ describe("fetchMessage", () => { expect(msg).toBeNull(); }); + it("populates author email when available", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + logger: mockLogger, + botUserId: "U_BOT", + }); + + mockClientMethod( + adapter, + "conversations.replies", + vi.fn().mockResolvedValue({ + ok: true, + messages: [ + { + type: "message", + user: "U1", + text: "Email test", + ts: "1234567890.123456", + channel: "C123", + }, + ], + }) + ); + + mockClientMethod( + adapter, + "users.info", + vi.fn().mockResolvedValue({ + ok: true, + user: { + name: "user1", + real_name: "User One", + profile: { email: "user1@example.com" }, + }, + }) + ); + + const state = createMockState(); + await adapter.initialize(createMockChatInstance(state)); + + const msg = await adapter.fetchMessage( + "slack:C123:1234567890.000000", + "1234567890.123456" + ); + + expect(msg?.author.email).toBe("user1@example.com"); + }); + it("sets author email to undefined when Slack profile email is missing", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + logger: mockLogger, + botUserId: "U_BOT", + }); + + mockClientMethod( + adapter, + "conversations.replies", + vi.fn().mockResolvedValue({ + ok: true, + messages: [ + { + type: "message", + user: "U1", + text: "No email scope", + ts: "1234567890.123456", + channel: "C123", + }, + ], + }) + ); + + mockClientMethod( + adapter, + "users.info", + vi.fn().mockResolvedValue({ + ok: true, + user: { + name: "user1", + real_name: "User One", + profile: {}, + }, + }) + ); + + const state = createMockState(); + await adapter.initialize(createMockChatInstance(state)); + + const msg = await adapter.fetchMessage( + "slack:C123:1234567890.000000", + "1234567890.123456" + ); + + expect(msg?.author.email).toBeUndefined(); + }); }); // ============================================================================ diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index ebc419d4..a1751f50 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -351,6 +351,7 @@ type SlackInteractivePayload = /** Cached user info */ interface CachedUser { displayName: string; + email?: string; realName: string; } @@ -700,14 +701,18 @@ export class SlackAdapter implements Adapter { */ private async lookupUser( userId: string - ): Promise<{ displayName: string; realName: string }> { + ): Promise<{ displayName: string; realName: string; email?: string }> { 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 { + displayName: cached.displayName, + realName: cached.realName, + email: cached.email, + }; } } @@ -718,7 +723,7 @@ export class SlackAdapter implements Adapter { const user = result.user as { name?: string; real_name?: string; - profile?: { display_name?: string; real_name?: string }; + profile?: { display_name?: string; real_name?: string; email?: string }; }; // Slack user naming: profile.display_name > profile.real_name > real_name > name > userId @@ -730,6 +735,7 @@ export class SlackAdapter implements Adapter { userId; const realName = user?.real_name || user?.profile?.real_name || displayName; + const email = user?.profile?.email; // Cache the result via state adapter if (this.chat) { @@ -737,7 +743,7 @@ export class SlackAdapter implements Adapter { .getState() .set( cacheKey, - { displayName, realName }, + { displayName, realName, email }, SlackAdapter.USER_CACHE_TTL_MS ); @@ -757,8 +763,9 @@ export class SlackAdapter implements Adapter { userId, displayName, realName, + email, }); - return { displayName, realName }; + return { displayName, realName, email }; } catch (error) { this.logger.warn("Could not fetch user info", { userId, error }); // Fall back to user ID @@ -1868,12 +1875,16 @@ export class SlackAdapter implements Adapter { // since Slack events only include the user ID, not the username let userName = event.username || "unknown"; let fullName = event.username || "unknown"; + let userEmail: string | undefined; // If we have a user ID but no username, look up the user info - if (event.user && !event.username) { + if (event.user) { const userInfo = await this.lookupUser(event.user); - userName = userInfo.displayName; - fullName = userInfo.realName; + if (!event.username) { + userName = userInfo.displayName; + fullName = userInfo.realName; + } + userEmail = userInfo.email; } // Track thread participants for outgoing mention resolution (skip dupes) @@ -1911,6 +1922,7 @@ export class SlackAdapter implements Adapter { userId: event.user || event.bot_id || "unknown", userName, fullName, + email: userEmail, isBot: !!event.bot_id, isMe, }, @@ -3372,6 +3384,7 @@ export class SlackAdapter implements Adapter { userId: event.user || event.bot_id || "unknown", userName, fullName, + email: undefined, isBot: !!event.bot_id, isMe, }, diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index b6ed81ec..6827b200 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -1068,6 +1068,8 @@ export interface RawMessage { } export interface Author { + /** Email address (if supported by platform and scopes) */ + email?: string; /** Display name */ fullName: string; /** Whether the author is a bot */