format user entity ref mentions to be Slack compatible

Signed-off-by: Jackson Chen <jacksonc@spotify.com>
This commit is contained in:
Jackson Chen
2025-05-05 15:19:00 -04:00
parent 714e86684e
commit e099d0a4ce
3 changed files with 181 additions and 1 deletions
+7
View File
@@ -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>!`
@@ -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'),
);
});
});
});
@@ -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<string | undefined> {
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<string, string>();
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<string | undefined> {