Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-mention-offset-user-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/slack": patch
---

Fix duplicate mention resolution by using the replace callback offset instead of indexOf. Invalidate user cache on Slack user_change events so display name updates are picked up immediately.
47 changes: 46 additions & 1 deletion packages/adapter-slack/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4927,7 +4927,7 @@ describe("reverse user lookup", () => {
}
).parseSlackMessage(event, threadId);

// Wait for fire-and-forget to complete
// Allow participant tracking to complete
await new Promise((resolve) => setTimeout(resolve, 10));

const participants = await state.getList(
Expand All @@ -4936,4 +4936,49 @@ describe("reverse user lookup", () => {
expect(participants).toContain("U_SENDER_1");
});
});

describe("user_change event", () => {
it("invalidates user cache on profile change", async () => {
const state = createMockState();
const adapter = createSlackAdapter({
botToken: "xoxb-test-token",
signingSecret: secret,
logger: mockLogger,
});
await adapter.initialize(createMockChatInstance(state));

// Seed user cache
await state.set(
"slack:user:U_DOM_123",
{ displayName: "dominik", realName: "Dominik G" },
8 * 24 * 60 * 60 * 1000
);

const body = JSON.stringify({
type: "event_callback",
event: {
type: "user_change",
event_ts: "1234567890.123456",
user: {
id: "U_DOM_123",
name: "dominik",
real_name: "Dominik New",
profile: {
display_name: "dom_new",
real_name: "Dominik New",
},
},
},
});
const request = createWebhookRequest(body, secret);
const response = await adapter.handleWebhook(request);

expect(response.status).toBe(200);

await new Promise((resolve) => setTimeout(resolve, 50));

const cached = await state.get("slack:user:U_DOM_123");
expect(cached).toBeNull();
});
});
});
80 changes: 56 additions & 24 deletions packages/adapter-slack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,18 @@ interface SlackMemberJoinedChannelEvent {
user: string;
}

/** Slack user_change event payload */
interface SlackUserChangeEvent {
event_ts: string;
type: "user_change";
user: {
id: string;
name?: string;
real_name?: string;
profile?: { display_name?: string; real_name?: string };
};
}

/** Slack webhook payload envelope */
interface SlackWebhookPayload {
challenge?: string;
Expand All @@ -253,7 +265,8 @@ interface SlackWebhookPayload {
| SlackAssistantThreadStartedEvent
| SlackAssistantContextChangedEvent
| SlackAppHomeOpenedEvent
| SlackMemberJoinedChannelEvent;
| SlackMemberJoinedChannelEvent
| SlackUserChangeEvent;
event_id?: string;
event_time?: number;
team_id?: string;
Expand Down Expand Up @@ -916,6 +929,8 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
event as SlackMemberJoinedChannelEvent,
options
);
} else if (event.type === "user_change") {
this.handleUserChange(event as SlackUserChangeEvent);
}
}
}
Expand Down Expand Up @@ -1573,6 +1588,21 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
);
}

private async handleUserChange(event: SlackUserChangeEvent): Promise<void> {
if (!this.chat) {
return;
}

try {
await this.chat.getState().delete(`slack:user:${event.user.id}`);
} catch (error) {
this.logger.warn("Failed to invalidate user cache", {
userId: event.user.id,
error,
});
}
}

/**
* Publish a Home tab view for a user.
* Slack API: views.publish
Expand Down Expand Up @@ -2021,32 +2051,34 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
}

// Replace mentions in text
return text.replace(mentionPattern, (match, name: string) => {
const idx = text.indexOf(match);
if (idx > 0 && text[idx - 1] === "<") {
return match;
}
if (SLACK_USER_ID_EXACT_PATTERN.test(name)) {
return match;
}
return text.replace(
mentionPattern,
(match, name: string, offset: number) => {
if (offset > 0 && text[offset - 1] === "<") {
return match;
}
if (SLACK_USER_ID_EXACT_PATTERN.test(name)) {
return match;
}

const userIds = mentions.get(name.toLowerCase());
if (!userIds || userIds.length === 0) {
return match;
}
if (userIds.length === 1) {
return `<@${userIds[0]}>`;
}
// Disambiguate using thread participants
if (participants) {
const inThread = userIds.filter((id) => participants.has(id));
if (inThread.length === 1) {
return `<@${inThread[0]}>`;
const userIds = mentions.get(name.toLowerCase());
if (!userIds || userIds.length === 0) {
return match;
}
if (userIds.length === 1) {
return `<@${userIds[0]}>`;
}
// Disambiguate using thread participants
if (participants) {
const inThread = userIds.filter((id) => participants.has(id));
if (inThread.length === 1) {
return `<@${inThread[0]}>`;
}
}
// Still ambiguous — leave as plain text
return match;
}
// Still ambiguous — leave as plain text
return match;
});
);
}

/**
Expand Down