diff --git a/.changeset/fix-mention-offset-user-change.md b/.changeset/fix-mention-offset-user-change.md new file mode 100644 index 00000000..04315ca9 --- /dev/null +++ b/.changeset/fix-mention-offset-user-change.md @@ -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. diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index c4e743d9..91cd0989 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -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( @@ -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(); + }); + }); }); diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index dfc16aa1..ebc419d4 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -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; @@ -253,7 +265,8 @@ interface SlackWebhookPayload { | SlackAssistantThreadStartedEvent | SlackAssistantContextChangedEvent | SlackAppHomeOpenedEvent - | SlackMemberJoinedChannelEvent; + | SlackMemberJoinedChannelEvent + | SlackUserChangeEvent; event_id?: string; event_time?: number; team_id?: string; @@ -916,6 +929,8 @@ export class SlackAdapter implements Adapter { event as SlackMemberJoinedChannelEvent, options ); + } else if (event.type === "user_change") { + this.handleUserChange(event as SlackUserChangeEvent); } } } @@ -1573,6 +1588,21 @@ export class SlackAdapter implements Adapter { ); } + private async handleUserChange(event: SlackUserChangeEvent): Promise { + 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 @@ -2021,32 +2051,34 @@ export class SlackAdapter implements Adapter { } // 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; - }); + ); } /**