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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/add-author-field-and-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"chat": minor
"@chat-adapter/slack": minor
---

Add new field on Author in core types and a new scope for Slack.
2 changes: 2 additions & 0 deletions examples/nextjs-chat/slack-manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions packages/adapter-slack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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**

Expand Down
96 changes: 96 additions & 0 deletions packages/adapter-slack/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

// ============================================================================
Expand Down
29 changes: 21 additions & 8 deletions packages/adapter-slack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ type SlackInteractivePayload =
/** Cached user info */
interface CachedUser {
displayName: string;
email?: string;
realName: string;
}

Expand Down Expand Up @@ -700,14 +701,18 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
*/
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<CachedUser>(cacheKey);
if (cached) {
return { displayName: cached.displayName, realName: cached.realName };
return {
displayName: cached.displayName,
realName: cached.realName,
email: cached.email,
};
}
}

Expand All @@ -718,7 +723,7 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
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
Expand All @@ -730,14 +735,15 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
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) {
await this.chat
.getState()
.set<CachedUser>(
cacheKey,
{ displayName, realName },
{ displayName, realName, email },
SlackAdapter.USER_CACHE_TTL_MS
);

Expand All @@ -757,8 +763,9 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
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
Expand Down Expand Up @@ -1868,12 +1875,16 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
// 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)
Expand Down Expand Up @@ -1911,6 +1922,7 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
userId: event.user || event.bot_id || "unknown",
userName,
fullName,
email: userEmail,
isBot: !!event.bot_id,
isMe,
},
Expand Down Expand Up @@ -3372,6 +3384,7 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
userId: event.user || event.bot_id || "unknown",
userName,
fullName,
email: undefined,
isBot: !!event.bot_id,
isMe,
},
Expand Down
2 changes: 2 additions & 0 deletions packages/chat/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1068,6 +1068,8 @@ export interface RawMessage<TRawMessage = unknown> {
}

export interface Author {
/** Email address (if supported by platform and scopes) */
email?: string;
/** Display name */
fullName: string;
/** Whether the author is a bot */
Expand Down