From e099d0a4cedf346c20f58c7e539c0a8a762f1f51 Mon Sep 17 00:00:00 2001 From: Jackson Chen Date: Mon, 5 May 2025 15:19:00 -0400 Subject: [PATCH] format user entity ref mentions to be Slack compatible Signed-off-by: Jackson Chen --- .changeset/true-trains-tie.md | 7 + .../lib/SlackNotificationProcessor.test.ts | 123 ++++++++++++++++++ .../src/lib/SlackNotificationProcessor.ts | 52 +++++++- 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 .changeset/true-trains-tie.md diff --git a/.changeset/true-trains-tie.md b/.changeset/true-trains-tie.md new file mode 100644 index 0000000000..678e6a85e9 --- /dev/null +++ b/.changeset/true-trains-tie.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-notifications-backend-module-slack': patch +--- + +Notifications which mention user entity refs are now replaced with Slack compatible mentions. + +Example: `Welcome <@user:default/billy>!` -> `Welcome <@U123456890>!` diff --git a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts index f5893ff456..87abf64717 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts @@ -543,4 +543,127 @@ describe('SlackNotificationProcessor', () => { expect(slack.chat.postMessage).not.toHaveBeenCalled(); }); }); + + describe('when replacing user entity refs with Slack IDs', () => { + const createBaseMessage = (text: string) => ({ + channel: 'U12345678', + text: 'notification', + attachments: [ + { + color: '#00A699', + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text, + }, + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'View More', + }, + action_id: 'button-action', + }, + }, + { + type: 'context', + elements: [ + { + type: 'plain_text', + text: 'Severity: normal', + emoji: true, + }, + { + type: 'plain_text', + text: 'Topic: N/A', + emoji: true, + }, + ], + }, + ], + fallback: 'notification', + }, + ], + }); + + it('should replace user entity refs with Slack compatible mentions', async () => { + const slack = new WebClient(); + const processor = SlackNotificationProcessor.fromConfig(config, { + auth, + discovery, + logger, + catalog: catalogServiceMock({ + entities: DEFAULT_ENTITIES_RESPONSE.items, + }), + slack, + })[0]; + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: 'user:default/mock', + created: new Date(), + payload: { + title: 'notification', + description: + 'Hello <@user:default/mock> and <@user:default/mock-without-slack-annotation>', + }, + }, + { + recipients: { type: 'entity', entityRef: 'user:default/mock' }, + payload: { + title: 'notification', + description: + 'Hello <@user:default/mock> and <@user:default/mock-without-slack-annotation>', + }, + }, + ); + + expect(slack.chat.postMessage).toHaveBeenCalledWith( + createBaseMessage( + 'Hello <@U12345678> and <@user:default/mock-without-slack-annotation>', + ), + ); + }); + + it('should handle text without user entity refs', async () => { + const slack = new WebClient(); + const processor = SlackNotificationProcessor.fromConfig(config, { + auth, + discovery, + logger, + catalog: catalogServiceMock({ + entities: DEFAULT_ENTITIES_RESPONSE.items, + }), + slack, + })[0]; + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: 'user:default/mock', + created: new Date(), + payload: { + title: 'notification', + description: 'Hello world', + }, + }, + { + recipients: { type: 'entity', entityRef: 'user:default/mock' }, + payload: { + title: 'notification', + description: 'Hello world', + }, + }, + ); + + expect(slack.chat.postMessage).toHaveBeenCalledWith( + createBaseMessage('Hello world'), + ); + }); + }); }); diff --git a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts index ca51eeb068..774150676c 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts @@ -236,8 +236,11 @@ export class SlackNotificationProcessor implements NotificationProcessor { } // Prepare outbound messages + const formattedPayload = await this.formatPayloadDescriptionForSlack( + options.payload, + ); const outbound = destinations.map(channel => - toChatPostMessageArgs({ channel, payload: options.payload }), + toChatPostMessageArgs({ channel, payload: formattedPayload }), ); // Log debug info @@ -249,6 +252,15 @@ export class SlackNotificationProcessor implements NotificationProcessor { await this.sendNotifications(outbound); } + private async formatPayloadDescriptionForSlack( + payload: Notification['payload'], + ) { + return { + ...payload, + description: await this.replaceUserRefsWithSlackIds(payload.description), + }; + } + async getEntities( entityRefs: readonly string[], ): Promise<(Entity | undefined)[]> { @@ -274,6 +286,44 @@ export class SlackNotificationProcessor implements NotificationProcessor { return response.items; } + async replaceUserRefsWithSlackIds( + text?: string, + ): Promise { + if (!text) return undefined; + + // Match user entity refs like "<@user:default/billy>" + const userRefRegex = /<@(user:[^>]+)>/gi; + const matches = [...text.matchAll(userRefRegex)]; + + if (matches.length === 0) return text; + + const uniqueUserRefs = new Set( + matches.map(match => match[1].toLowerCase()), + ); + + const slackIdMap = new Map(); + + await Promise.all( + [...uniqueUserRefs].map(async userRef => { + try { + const slackId = await this.getSlackNotificationTarget(userRef); + if (slackId) { + slackIdMap.set(userRef, `<@${slackId}>`); + } + } catch (error) { + this.logger.warn( + `Failed to resolve Slack ID for user ref "${userRef}": ${error}`, + ); + } + }), + ); + + return text.replace(userRefRegex, (match, userRef) => { + const slackId = slackIdMap.get(userRef.toLowerCase()); + return slackId ?? match; + }); + } + async getSlackNotificationTarget( entityRef: string, ): Promise {